toolroll 0.9.65 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +20 -2
  3. package/dist/agent-fence.d.ts +69 -2
  4. package/dist/agent-fence.js +291 -11
  5. package/dist/agentconfig.js +4 -1
  6. package/dist/batch-checks.js +3 -2
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
  8. package/dist/browser/terminal.js +134 -23
  9. package/dist/browser/workspace.css +1 -1
  10. package/dist/builder.d.ts +4 -0
  11. package/dist/builder.js +54 -12
  12. package/dist/contracts/console-api.d.ts +1 -0
  13. package/dist/contracts/console-api.js +1 -0
  14. package/dist/invoke.d.ts +18 -0
  15. package/dist/invoke.js +137 -14
  16. package/dist/live-terminal.d.ts +24 -13
  17. package/dist/live-terminal.js +79 -42
  18. package/dist/operate.d.ts +2 -0
  19. package/dist/operate.js +60 -2
  20. package/dist/project-delete-ui.d.ts +6 -0
  21. package/dist/project-delete-ui.js +20 -1
  22. package/dist/release-adapters.d.ts +6 -0
  23. package/dist/release-adapters.js +70 -3
  24. package/dist/release.d.ts +7 -1
  25. package/dist/release.js +20 -7
  26. package/dist/result-follow-ups.js +3 -2
  27. package/dist/scope.d.ts +13 -0
  28. package/dist/scope.js +10 -5
  29. package/dist/serve.js +1 -0
  30. package/dist/server/chrome.d.ts +10 -1
  31. package/dist/server/chrome.js +45 -4
  32. package/dist/server/console.css +55 -4
  33. package/dist/server/render-pages.d.ts +28 -3
  34. package/dist/server/render-pages.js +78 -9
  35. package/dist/server/render-tasks.js +2 -1
  36. package/dist/server/route-table.js +1 -0
  37. package/dist/server/settings.js +21 -0
  38. package/dist/server/tasks.js +7 -4
  39. package/dist/store.d.ts +11 -1
  40. package/dist/store.js +33 -2
  41. package/dist/teardown-git.d.ts +22 -0
  42. package/dist/teardown-git.js +136 -2
  43. package/dist/tool-broker.d.ts +12 -0
  44. package/dist/tool-broker.js +66 -2
  45. package/dist/unfenced-test-support.d.ts +2 -0
  46. package/dist/unfenced-test-support.js +11 -0
  47. package/dist/writes-mode.d.ts +9 -0
  48. package/dist/writes-mode.js +8 -0
  49. package/package.json +5 -1
package/dist/builder.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { legacyNetwork, repositoryEcosystems } from "./egress.js";
2
+ import { checkoutGit } from "./teardown-git.js";
2
3
  import { isAcpProvider } from "./acp-agents.js";
3
4
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
4
5
  import { checkCommandFor, effectiveCheckLevel, recordRunCheckLevel } from "./check-levels.js";
@@ -41,9 +42,9 @@ import { PREPARED_EVIDENCE_FILE, PREPARED_EVIDENCE_GIT, preparedScreenshotMatche
41
42
  * watchdog and a high runaway-turn breaker still stop a pathological loop;
42
43
  * repair remains narrowly time-bounded because its job is narrowly scoped.
43
44
  */
44
- import { existsSync, unlinkSync, writeFileSync } from "node:fs";
45
+ import { existsSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
45
46
  import { homedir } from "node:os";
46
- import { join } from "node:path";
47
+ import { join, resolve } from "node:path";
47
48
  import { run, runClaudeStreamJsonl, runOwnerTag, streamsInput } from "./exec.js";
48
49
  import { stopRequestedFor, stopWords, underStopWatch } from "./task-control.js";
49
50
  import { witnessedRunner } from "./process-custody.js";
@@ -58,7 +59,7 @@ import { heartbeat as runnerHeartbeat } from "./runner.js";
58
59
  import { MARKER as LEASE_MARKER } from "./worktree.js";
59
60
  import { parseDecision, parseHandoff, repairPrompt, HEADLESS_RULE, HANDOFF_CONCLUSION_CAP, HANDOFF_ITEM_CAP, HANDOFF_LIST_CAP, HANDOFF_PAYLOAD_CAP } from "./decision.js";
60
61
  import { createHash, randomBytes, randomUUID } from "node:crypto";
61
- import { invokeAgent } from "./invoke.js";
62
+ import { fencedCommands, invokeAgent } from "./invoke.js";
62
63
  import { TELEGRAM_TOKEN_ENVS } from "./names.js";
63
64
  import { OPENROUTER_ENV_KEY, auditOf, ALL_CREDENTIAL_ENV } from "./provider.js";
64
65
  import { liveLogContext } from "./live-detail.js";
@@ -237,6 +238,43 @@ export function launchNetworkOf(store, taskRef, profile) {
237
238
  const repo = store.refForId(taskRef)?.repo ?? null;
238
239
  return { policy: legacyNetwork(profile.provider, repo === null ? [] : repositoryEcosystems(repo)) };
239
240
  }
241
+ /** Asking a checkout which branch it is on: reads no file a build wrote and runs nothing. */
242
+ const BRANCH_QUERIES = [
243
+ ["--no-optional-locks", "rev-parse", "--abbrev-ref", "HEAD"], ["--no-optional-locks", "symbolic-ref", "--short", "-q", "HEAD"],
244
+ ];
245
+ /**
246
+ * The plane's git as a build runs it: hardened (teardown-git.ts checkoutGit) for every call, failing closed, but one —
247
+ * asking the project's own main checkout which branch it is on (its config is the operator's, and the query runs
248
+ * nothing). An injected runner (`request.git`: tests only; production always runs git itself) is used as given.
249
+ */
250
+ function buildCheckoutGit(given, worktree, mainCheckout = null) {
251
+ if (given !== run)
252
+ return given;
253
+ const hardened = checkoutGit(given);
254
+ const real = (path) => { try {
255
+ return realpathSync(resolve(path));
256
+ }
257
+ catch {
258
+ return null;
259
+ } };
260
+ const trusted = mainCheckout === null ? null : real(mainCheckout);
261
+ const own = real(worktree);
262
+ return (file, args, options) => {
263
+ const at = options?.cwd === undefined ? null : real(options.cwd);
264
+ const query = BRANCH_QUERIES.some(one => one.length === args.length && one.every((word, index) => args[index] === word));
265
+ return trusted !== null && at === trusted && at !== own && query ? given(file, args, options) : hardened(file, args, options);
266
+ };
267
+ }
268
+ /** A run's setup and check commands, in its build's fence, writing where the approval this build runs under sealed
269
+ * (never the scope as it may since have been rewritten; invoke.ts fencedCommands). */
270
+ function commandRunner(store, runId, profile, inner) {
271
+ return fencedCommands(store, runId, launchWritesOf(profile), inner);
272
+ }
273
+ /** Where a build may write (writes-mode.ts): what its approval sealed, or — sealed before write allowlists — anywhere
274
+ * its fence allows, as it was approved. */
275
+ export function launchWritesOf(profile) {
276
+ return profile.writes ?? "anywhere";
277
+ }
240
278
  export function proveApprovedProfile(scope, given) {
241
279
  // The raw terms first (raw authority repair): a scope whose stored terms
242
280
  // do not read back exactly proves nothing — not the filtered reading.
@@ -331,7 +369,9 @@ export function proveApprovedProfile(scope, given) {
331
369
  * that forgot one is exactly the caller this is protecting against.
332
370
  */
333
371
  export async function build(store, request) {
334
- const { taskId, taskRef, runner, worktree, branch, now, permissionMode = "auto", skipPermissions = false, provider = "claude", model, maxTurns = DEFAULT_MAX_TURNS, timeoutMs = DEFAULT_BUILD_TIMEOUT_MS, agent, git = run, } = request;
372
+ const { taskId, taskRef, runner, worktree, branch, now, permissionMode = "auto", skipPermissions = false, provider = "claude", model, maxTurns = DEFAULT_MAX_TURNS, timeoutMs = DEFAULT_BUILD_TIMEOUT_MS, agent, git: givenGit = run, } = request;
373
+ // The plane's own git on this build's checkout runs hardened, and never where the checkout's config names a command.
374
+ const git = buildCheckoutGit(givenGit, worktree, store.getWorktree(worktree)?.repo ?? null);
335
375
  const scope = store.getScope(taskId);
336
376
  const approval = approvalOf(scope);
337
377
  if (!approval.approved) {
@@ -625,7 +665,7 @@ export async function build(store, request) {
625
665
  // Setup runs under the stop watch (v52), owned by this run: an
626
666
  // operator's stop ends the setup's process group and the attempt
627
667
  // settles as interrupted below, never as a setup failure.
628
- const made = await underStopWatch(store, request.runId, () => runWithIsolatedDatabase(witnessedRunner(store, request.runId, request.clock ?? (() => now), runSetup), shell.file, shell.args, {
668
+ const made = await underStopWatch(store, request.runId, () => runWithIsolatedDatabase(witnessedRunner(store, request.runId, request.clock ?? (() => now), commandRunner(store, request.runId, effective.profile, runSetup)), shell.file, shell.args, {
629
669
  cwd: worktree,
630
670
  timeoutMs: setupWanted.timeoutMs,
631
671
  processGroup: true,
@@ -1310,6 +1350,7 @@ export async function build(store, request) {
1310
1350
  ...watchdogsOf(effective.profile, effective.timeoutMs),
1311
1351
  omitEnv: AGENT_ENV_DENYLIST,
1312
1352
  network: launchNetworkOf(store, request.taskRef, effective.profile),
1353
+ writes: launchWritesOf(effective.profile),
1313
1354
  ...(agent === undefined ? {} : { runner: agent }),
1314
1355
  onSpawn: (pid) => { spawned = true; request.onProviderSpawn?.(pid); },
1315
1356
  onStreamEvent: (event) => liveLog.observe(event),
@@ -1774,7 +1815,7 @@ export async function settleProviderOutcome(captured, result) {
1774
1815
  };
1775
1816
  if (!eligible())
1776
1817
  throw Error("The observation source or execution authority changed.");
1777
- const execute = (file, args, options) => underStopWatch(store, request.runId, () => runWithIsolatedDatabase(witnessedRunner(store, request.runId, clock, request.verify ?? run), file, args, {
1818
+ const execute = (file, args, options) => underStopWatch(store, request.runId, () => runWithIsolatedDatabase(witnessedRunner(store, request.runId, clock, commandRunner(store, request.runId, captured.effective.profile, request.verify ?? run)), file, args, {
1778
1819
  ...options, processGroup: true, owner: runOwnerTag(store, request.runId), beforeSpawn: eligible,
1779
1820
  onSpawn: pid => request.onProviderSpawn?.(pid), envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST,
1780
1821
  }));
@@ -2234,7 +2275,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2234
2275
  const runVerification = async (label) => {
2235
2276
  const streamed = { stdout: false, stderr: false };
2236
2277
  // The check runs under the stop watch (v52), owned by this run.
2237
- const result = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, verifyRunner), verifyShell.file, verifyShell.args, {
2278
+ const result = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, commandRunner(store, runId, captured.effective.profile, verifyRunner)), verifyShell.file, verifyShell.args, {
2238
2279
  cwd: worktree,
2239
2280
  timeoutMs: configured.timeoutMs,
2240
2281
  processGroup: true,
@@ -2276,7 +2317,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2276
2317
  detachShared(worktree, depsRoot);
2277
2318
  const label = "Own install · approved project setup (package.json or lockfile changed)";
2278
2319
  const setupShell = approvedCommandShell(live.command);
2279
- const installed = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, request.setup ?? run), setupShell.file, setupShell.args, {
2320
+ const installed = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, commandRunner(store, runId, captured.effective.profile, request.setup ?? run)), setupShell.file, setupShell.args, {
2280
2321
  cwd: worktree,
2281
2322
  timeoutMs: live.timeoutMs,
2282
2323
  processGroup: true,
@@ -2295,7 +2336,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2295
2336
  checkLog.push(attemptLog(label, live.command, installed));
2296
2337
  if (installed.notFound || installed.timedOut || installed.code !== 0)
2297
2338
  return "own-install-failed";
2298
- return await sealedTreeState(request.git ?? run) === "clean" ? null : "setup-changed-files";
2339
+ return await sealedTreeState(buildCheckoutGit(request.git ?? run, worktree)) === "clean" ? null : "setup-changed-files";
2299
2340
  };
2300
2341
  const ownInstall = await ownInstallIfChanged();
2301
2342
  const first = ownInstall === null ? await runVerification("Project check · attempt 1") : null;
@@ -2339,7 +2380,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2339
2380
  verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "custody-lost" };
2340
2381
  }
2341
2382
  else {
2342
- const gitRunner = request.git ?? run;
2383
+ const gitRunner = buildCheckoutGit(request.git ?? run, worktree);
2343
2384
  const beforeSetup = await sealedTreeState(gitRunner);
2344
2385
  if (beforeSetup === "head-moved") {
2345
2386
  verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "checkout-moved" };
@@ -2366,7 +2407,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
2366
2407
  else {
2367
2408
  const setupRunner = request.setup ?? run;
2368
2409
  const setupShell = approvedCommandShell(liveBeforeSetup.command);
2369
- const restored = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, setupRunner), setupShell.file, setupShell.args, {
2410
+ const restored = await underStopWatch(store, runId, () => runWithIsolatedDatabase(witnessedRunner(store, runId, now, commandRunner(store, runId, captured.effective.profile, setupRunner)), setupShell.file, setupShell.args, {
2370
2411
  cwd: worktree,
2371
2412
  timeoutMs: liveBeforeSetup.timeoutMs,
2372
2413
  processGroup: true,
@@ -2653,6 +2694,7 @@ async function resumeUnhandedWork(captured, sessionId) {
2653
2694
  ...watchdogsOf(effective.profile, effective.timeoutMs),
2654
2695
  omitEnv: AGENT_ENV_DENYLIST,
2655
2696
  network: launchNetworkOf(store, request.taskRef, effective.profile),
2697
+ writes: launchWritesOf(effective.profile),
2656
2698
  ...(agent === undefined ? {} : { runner: agent }),
2657
2699
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
2658
2700
  clock,
@@ -2867,7 +2909,7 @@ async function ingestPark(args) {
2867
2909
  ? watchdogsOf(args.profile, args.profile.repairTimeoutSeconds * 1000, true)
2868
2910
  : { timeoutMs: args.profile !== undefined ? args.profile.repairTimeoutSeconds * 1000 : REPAIR_TIMEOUT_MS }),
2869
2911
  omitEnv: AGENT_ENV_DENYLIST,
2870
- ...(args.profile === undefined ? {} : { network: launchNetworkOf(store, request.taskRef, args.profile) }),
2912
+ ...(args.profile === undefined ? {} : { network: launchNetworkOf(store, request.taskRef, args.profile), writes: launchWritesOf(args.profile) }),
2871
2913
  ...(agent === undefined ? {} : { runner: agent }),
2872
2914
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
2873
2915
  clock,
@@ -83,6 +83,7 @@ export declare const CONSOLE_FORMS: {
83
83
  readonly projectConcurrency: FormContract<"concurrency" | "repo">;
84
84
  readonly projectLiveView: FormContract<"act" | "nonce" | "password" | "repo">;
85
85
  readonly projectNetwork: FormContract<"hosts" | "local" | "mode" | "password" | "repo">;
86
+ readonly projectWrites: FormContract<"password" | "repo" | "writes">;
86
87
  readonly projectDelete: FormContract<"name" | "password" | "repo" | "step">;
87
88
  readonly policy: FormContract<"ceiling" | "models" | "password" | "provider" | "tools">;
88
89
  readonly approval: FormContract<"not_requester" | "password" | "paths" | "protect" | "repo">;
@@ -92,6 +92,7 @@ export const CONSOLE_FORMS = {
92
92
  projectConcurrency: formContract("POST /settings/project/concurrency", ["repo", "concurrency"]),
93
93
  projectLiveView: formContract("POST /settings/project/live-view", ["repo", "act", "password", "nonce"]),
94
94
  projectNetwork: formContract("POST /settings/project/network", ["repo", "mode", "local", "hosts", "password"]),
95
+ projectWrites: formContract("POST /settings/project/writes", ["repo", "writes", "password"]),
95
96
  projectDelete: formContract("POST /settings/project/delete", ["repo", "name", "step", "password"]),
96
97
  policy: formContract("POST /settings/policy", ["password", "provider", "models", "tools", "ceiling"]),
97
98
  approval: formContract("POST /settings/approval", ["repo", "password", "protect", "paths", "not_requester"]),
package/dist/invoke.d.ts CHANGED
@@ -20,6 +20,8 @@ import { type AgentSpec, type Invocation, type ProviderRunner, type AgentEnding
20
20
  import { type VersionProbe } from "./attest.js";
21
21
  import type { Store } from "./store.js";
22
22
  import type { RunOptions } from "./exec.js";
23
+ import type { Runner } from "./worktree.js";
24
+ import { type WritesMode } from "./agent-fence.js";
23
25
  import { type NetworkProfile } from "./egress.js";
24
26
  export type { ProviderRunner } from "./provider.js";
25
27
  /** The claude binary name — kept for the legacy quota rows and tests that
@@ -109,4 +111,20 @@ export declare function invokeAgent(store: Store, runId: number, spec: AgentSpec
109
111
  keyHome?: string;
110
112
  accumulateUsage?: boolean;
111
113
  network?: LaunchNetwork;
114
+ writes?: WritesMode;
112
115
  }): Promise<InvokeResult>;
116
+ /**
117
+ * A build's setup and check commands, run in its fence (arc 2): the same denies as its agent's (Toolroll's state,
118
+ * credentials, other projects and runs), its write allowlist when its approval sealed one (its checkout and Git data,
119
+ * its project's caches, and a temp folder of the command's own), and its package managers pointed at those caches.
120
+ * Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
121
+ * (a checkout whose binding no longer proves) runs nothing.
122
+ */
123
+ export declare function fencedCommands(store: Store, runId: number, writes: WritesMode, inner: Runner, available?: () => boolean): Runner;
124
+ /**
125
+ * A project's checks run in a checkout of a result's commit made for them (result-follow-ups.ts, batch-checks.ts), in
126
+ * the same fence as a build's commands: Toolroll's state, credentials, other projects and runs denied; under the
127
+ * project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
128
+ * database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
129
+ */
130
+ export declare function fencedCheckoutCommands(store: Store, repo: string, checkout: string, inner: Runner, available?: () => boolean): Runner;
package/dist/invoke.js CHANGED
@@ -29,12 +29,12 @@ import { witnessedRunner } from "./process-custody.js";
29
29
  import { underStopWatch } from "./task-control.js";
30
30
  import { childDatabaseEnv, isolatedChildDatabase, removeChildDatabase as removeAgentDatabase } from "./child-database.js";
31
31
  import { RunSignInError, noToolsArgs, prepareRunTools } from "./project-tools.js";
32
- import { mkdirSync, readFileSync, realpathSync } from "node:fs";
33
- import { homedir } from "node:os";
32
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
33
+ import { homedir, tmpdir } from "node:os";
34
34
  import { dirname, join } from "node:path";
35
35
  import { poolRootOf } from "./worktree.js";
36
36
  import { BRIDGE_DIR } from "./agent-fence.js";
37
- import { agentFence, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
37
+ import { agentFence, buildCacheEnv, buildCacheFolder, grantFolder, writeAllowlist, WRITES_DEFAULT, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
38
38
  import { proxyEnv, providerOnlyNetwork, startEgressProxy } from "./egress.js";
39
39
  import { bridgeFits, startEgressBridge } from "./egress-bridge.js";
40
40
  import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
@@ -239,7 +239,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
239
239
  // re-supply survives resolveChildEnv's chat-key strip); in
240
240
  // "subscription" mode the key is STRIPPED from the child so the CLI's
241
241
  // own login is used — its key is retained, just not handed over.
242
- const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, ...runOptions } = options;
242
+ const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, writes: launchWrites, ...runOptions } = options;
243
243
  // The live setting is read STRICTLY (atomic authority closure): a
244
244
  // present mode file that says neither word is a value-shaped refusal
245
245
  // before any stamp or process — never the subscription default a
@@ -300,12 +300,21 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
300
300
  // The project's tools (v80): exactly its MCP servers, and none of the
301
301
  // operator's global or a repository's own, served by this launch's broker
302
302
  // (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
303
+ // Set once the fence is known: a fenced build's servers never start outside a fence.
304
+ let serverMustBeFenced = false;
305
+ // What the build writes (its checkout, its project's caches): a tool server never runs from there, nor reads it at all.
306
+ const serverUntrusted = workspace === null ? [] : toolUntrusted(store, workspace, keyHome);
303
307
  const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
304
308
  env: { ...process.env, ...(runOptions.env ?? {}) },
305
309
  omitEnv: [...(runOptions.omitEnv ?? []), ...ALL_CREDENTIAL_ENV],
306
310
  pass: childDatabaseEnv(isolatedDb.file),
307
- ...(workspace === null ? {} : { cwd: workspace }),
311
+ // A tool server starts in a folder the build can't write, and never runs the build's own bytes (arc 2).
312
+ cwd: keyHome ?? homedir(),
313
+ untrusted: serverUntrusted,
308
314
  wrap: () => serverFence,
315
+ // A fenced build's tool servers start only in a fence too, unless an instance operator allowed unconfined agents.
316
+ unwrapped: () => serverMustBeFenced && !store.unfencedAgentsAllowed()
317
+ ? "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." : null,
309
318
  });
310
319
  if (launchTools !== null && "refused" in launchTools) {
311
320
  removeAgentDatabase(isolatedDb.dir);
@@ -333,7 +342,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
333
342
  // tool servers outside its command sandbox, so its own launch folder is
334
343
  // fenced too; Claude reads its config from there, inside the sandbox.
335
344
  const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
336
- const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools);
345
+ const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], cache: undefined, problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools, launchWrites ?? WRITES_DEFAULT);
337
346
  if (fenceOf.problem !== undefined) {
338
347
  tools?.cleanup();
339
348
  codexHome?.cleanup();
@@ -372,12 +381,16 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
372
381
  // A tool server the broker starts is fenced by what this platform supports, whatever the provider: Codex's own
373
382
  // profile fences only its agent's commands, and the broker starts servers outside it.
374
383
  if (fence.length > 0) {
375
- // A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network.
376
- const serverGrants = () => { const { network: _agentOnly, ...rest } = grants; return rest; };
384
+ // A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network,
385
+ // nor its write allowlist (a server keeps its own state where it always has).
386
+ const serverGrants = () => { const { network: _agentOnly, writes: _agentWrites, ...rest } = grants; return rest; };
387
+ // Nor read what the build wrote: however its arguments are spelled, no byte of the checkout or caches can load.
388
+ const serverDenies = [...fence, ...serverUntrusted.filter(path => existsSync(path)).map(path => realpathSync(path))];
377
389
  if (macosFenceAvailable())
378
- serverFence = (file, args) => macosFenced(file, args, fence, serverGrants());
390
+ serverFence = (file, args) => macosFenced(file, args, serverDenies, serverGrants());
379
391
  else if (linuxFenceAvailable())
380
- serverFence = (file, args) => linuxFenced(file, args, fence, serverGrants());
392
+ serverFence = (file, args) => linuxFenced(file, args, serverDenies, serverGrants());
393
+ serverMustBeFenced = true;
381
394
  }
382
395
  // No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
383
396
  // rules, Gemini to nothing — doesn't start unless the instance operator allowed unconfined agents here. An injected
@@ -457,6 +470,10 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
457
470
  ...(egress === null || !("policy" in network) ? {} : proxyEnv(egress, network.policy.loopback)),
458
471
  // Its own temp folder, where its tools' sockets may live (the only one its fence admits besides its checkout).
459
472
  ...(runTemp === null ? {} : { TMPDIR: runTemp, TMP: runTemp, TEMP: runTemp }),
473
+ // Claude's own working folder goes there too (it is otherwise the shared /tmp/claude-<uid>).
474
+ ...(runTemp === null || spec.provider !== "claude" ? {} : { CLAUDE_CODE_TMPDIR: runTemp }),
475
+ // A workspace build's package managers keep their caches in its project's own folder (arc 2).
476
+ ...(fenceOf.cache === undefined || grants.writes === undefined ? {} : buildCacheEnv(fenceOf.cache)),
460
477
  // This key is deliberately last: no caller may point an agent back at
461
478
  // the live control database through a generic RunOptions override.
462
479
  ...childDatabaseEnv(isolatedDb.file),
@@ -906,7 +923,7 @@ function linkedFor(store, worktree) {
906
923
  /** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
907
924
  * ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
908
925
  * (FenceGrants); and what only Claude's file tools refuse. */
909
- function runFence(store, runId, provider, keyHome, ownTools) {
926
+ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT) {
910
927
  try {
911
928
  // A plane always has a database file; an in-memory store (a test) has nothing to fence.
912
929
  const databaseFile = store.databaseFile();
@@ -921,22 +938,128 @@ function runFence(store, runId, provider, keyHome, ownTools) {
921
938
  const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
922
939
  const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
923
940
  const folders = runFolders(ownTools);
941
+ // A build with a checkout writes only its workspace unless its approval said anywhere (arc 2): its checkout and
942
+ // Git data, its launch folder, its project's caches and the provider state its CLI must write.
943
+ const allowed = worktree === null || row === null || writes !== "workspace" ? null
944
+ : writeAllowlist({ provider, home: keyHome ?? homedir(), worktree, binding, launchFolder: ownTools, cache: buildCacheFolder(row.repo, keyHome ?? homedir()) });
945
+ const fence = agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] });
946
+ const grants = fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home, poolRoots });
924
947
  return {
925
- fence: agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders }),
948
+ fence,
926
949
  fileTools: boundary?.fileTools ?? [],
927
950
  // The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
928
951
  // Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
929
952
  // checkout was recorded in (a --pool elsewhere), so another project's worktree, present at launch, is out of reach. Native wrappers also cover later creations.
930
- grants: fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home,
931
- poolRoots }),
953
+ grants: allowed === null ? grants : { ...grants, writes: allowed.writes, readOnly: [...new Set([...grants.readOnly, ...allowed.readOnly])].sort() },
932
954
  // The checkout's own Git data, which a private /tmp gives back beside the checkout (agent-fence.ts linuxFenced).
933
955
  gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
956
+ ...(allowed === null || row === null ? {} : { cache: buildCacheFolder(row.repo, keyHome ?? homedir()) }),
934
957
  };
935
958
  }
936
959
  catch (error) {
937
960
  return { fence: [], fileTools: [], grants: NO_GRANTS, problem: error instanceof CheckoutBindingError ? error.message : "The build fence could not be prepared." };
938
961
  }
939
962
  }
963
+ /**
964
+ * A build's setup and check commands, run in its fence (arc 2): the same denies as its agent's (Toolroll's state,
965
+ * credentials, other projects and runs), its write allowlist when its approval sealed one (its checkout and Git data,
966
+ * its project's caches, and a temp folder of the command's own), and its package managers pointed at those caches.
967
+ * Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
968
+ * (a checkout whose binding no longer proves) runs nothing.
969
+ */
970
+ export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
971
+ // An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
972
+ if (inner !== runCommand)
973
+ return inner;
974
+ return async (file, args, options) => {
975
+ // As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents.
976
+ if (!available()) {
977
+ if (store.unfencedAgentsAllowed())
978
+ return inner(file, args, options);
979
+ return { code: 1, stdout: "", stderr: "This machine can't confine the build's commands (no macOS sandbox, no working bubblewrap), so they didn't run. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
980
+ }
981
+ const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
982
+ try {
983
+ const temp = join(own, "tmp");
984
+ mkdirSync(temp, { mode: 0o700 });
985
+ const fenced = runFence(store, runId, "command", undefined, own, writes);
986
+ if (fenced.problem !== undefined)
987
+ return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
988
+ if (fenced.fence.length === 0)
989
+ return inner(file, args, options);
990
+ // A self-hosted check's own database (child-database.ts) is the command's to write too.
991
+ const database = options?.env?.["TOOLROLL_DB"];
992
+ const checkData = database === undefined || fenced.grants.writes === undefined ? null : grantFolder(dirname(database), false);
993
+ const grants = checkData === null ? fenced.grants : { ...fenced.grants, writes: { ...fenced.grants.writes, subpaths: [...fenced.grants.writes.subpaths, checkData] } };
994
+ return await inner(file, args, {
995
+ ...options,
996
+ fence: fenced.fence,
997
+ fenceGrants: grants,
998
+ env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)) },
999
+ });
1000
+ }
1001
+ finally {
1002
+ rmSync(own, { recursive: true, force: true });
1003
+ }
1004
+ };
1005
+ }
1006
+ /**
1007
+ * A project's checks run in a checkout of a result's commit made for them (result-follow-ups.ts, batch-checks.ts), in
1008
+ * the same fence as a build's commands: Toolroll's state, credentials, other projects and runs denied; under the
1009
+ * project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
1010
+ * database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
1011
+ */
1012
+ export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
1013
+ if (inner !== runCommand)
1014
+ return inner;
1015
+ return async (file, args, options) => {
1016
+ if (!available()) {
1017
+ if (store.unfencedAgentsAllowed())
1018
+ return inner(file, args, options);
1019
+ return { code: 1, stdout: "", stderr: "This machine can't confine the project's checks (no macOS sandbox, no working bubblewrap), so they didn't run. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
1020
+ }
1021
+ const databaseFile = store.databaseFile();
1022
+ if (databaseFile === null)
1023
+ return inner(file, args, options);
1024
+ const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
1025
+ try {
1026
+ const temp = join(own, "tmp");
1027
+ mkdirSync(temp, { mode: 0o700 });
1028
+ let binding;
1029
+ try {
1030
+ binding = trustedWorktreeBinding(repo, checkout);
1031
+ }
1032
+ catch {
1033
+ return { code: 1, stdout: "", stderr: "The check's checkout couldn't be bound to its project, so nothing ran.\n", timedOut: false, notFound: false };
1034
+ }
1035
+ const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
1036
+ const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
1037
+ const folders = runFolders(own);
1038
+ const cache = buildCacheFolder(repo);
1039
+ const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
1040
+ const database = options?.env?.["TOOLROLL_DB"];
1041
+ const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
1042
+ const base = fenceGrants({ databaseFile, worktree: checkout, boundary, runFolders: folders, linked: linkedFor(store, checkout), binding, poolRoots });
1043
+ const grants = allowed === null ? base
1044
+ : { ...base, readOnly: [...new Set([...base.readOnly, ...allowed.readOnly])].sort(), writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] } };
1045
+ return await inner(file, args, {
1046
+ ...options,
1047
+ fence: agentFence({ databaseFile, worktree: checkout, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] }),
1048
+ fenceGrants: grants,
1049
+ env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)) },
1050
+ });
1051
+ }
1052
+ finally {
1053
+ rmSync(own, { recursive: true, force: true });
1054
+ }
1055
+ };
1056
+ }
1057
+ /** What a build writes that a tool server must never run from: its checkout and its project's caches. A checkout with
1058
+ * no record never gets this far: its fence refuses the launch (runFence) before any tool server can start. */
1059
+ function toolUntrusted(store, workspace, keyHome) {
1060
+ const row = store.getWorktree(workspace);
1061
+ return [workspace, ...(row === null ? [] : [buildCacheFolder(row.repo, keyHome ?? homedir())])];
1062
+ }
940
1063
  /** The operator's own model choices a build routed with no model keeps (provider-home.ts). */
941
1064
  function inheritedDefaults(provider, env, home) {
942
1065
  if (provider === "claude")
@@ -6,8 +6,6 @@ export declare const MAX_LIVE_TERMINALS = 8;
6
6
  export declare const MAX_KEPT_TERMINALS = 16;
7
7
  /** At most this many pages attached to one session (a desktop, a phone, a spare tab or two). */
8
8
  export declare const MAX_VIEWERS = 4;
9
- /** Raw output kept per session for a page that attaches later. */
10
- export declare const SCROLLBACK_BYTES: number;
11
9
  /** After SIGHUP, the terminal's processes have this long before SIGKILL. */
12
10
  export declare const CLOSE_GRACE_MS = 2000;
13
11
  /** Typed input not yet read by the program, per terminal: past this, more is refused until it reads. */
@@ -54,15 +52,24 @@ export declare const ttyName: (path: unknown) => string | null;
54
52
  export declare function pendingInputOf(pty: unknown): number;
55
53
  /** node-pty, if this install has it (an optional dependency with prebuilt binaries); null when it doesn't load. */
56
54
  export declare function loadPtySpawn(): PtySpawn | null;
57
- /** Raw output, oldest dropped first, never more than `cap` bytes. */
58
- export declare class Scrollback {
59
- private readonly cap;
60
- private chunks;
61
- private size;
62
- constructor(cap?: number);
63
- push(chunk: Buffer): void;
64
- bytes(): Buffer;
65
- get length(): number;
55
+ /** How many lines above the screen a terminal keeps for a page that attaches later. */
56
+ export declare const SCREEN_SCROLLBACK_LINES = 5000;
57
+ /**
58
+ * The terminal as a page would see it now: every byte the program writes is parsed by a headless xterm here, so a page
59
+ * that attaches later gets the screen, its scrollback and its modes (the alternate screen of a full-screen program, the
60
+ * cursor, colours, bracketed paste, mouse reporting) — never a replay of raw output cut at an arbitrary byte, which
61
+ * could split an escape sequence or redraw a full-screen program garbled.
62
+ */
63
+ export declare class ScreenMirror {
64
+ private readonly lines;
65
+ private readonly terminal;
66
+ private readonly serializer;
67
+ constructor(cols: number, rows: number, lines?: number);
68
+ write(data: string): void;
69
+ resize(cols: number, rows: number): void;
70
+ /** The screen as of this call (after everything written before it), handed to `done` once parsed. */
71
+ snapshot(done: (screen: string) => void): void;
72
+ dispose(): void;
66
73
  }
67
74
  /** One attached page. The host never waits on it. */
68
75
  export type TerminalViewer = {
@@ -138,7 +145,8 @@ export type TerminalHostOptions = {
138
145
  /** Start, close and remove, for the event log (never input or output). */
139
146
  record?: (event: "start" | "close" | "remove" | "exit", session: TerminalSummary) => void;
140
147
  maxSessions?: number;
141
- scrollbackBytes?: number;
148
+ /** Lines above the screen a terminal keeps for a page that attaches later (SCREEN_SCROLLBACK_LINES). */
149
+ scrollbackLines?: number;
142
150
  closeGraceMs?: number;
143
151
  ledger?: TerminalLedger;
144
152
  /** The process table (tests stand a failing one in); null when it can't be read. */
@@ -175,7 +183,10 @@ export declare class TerminalHost {
175
183
  cols: number;
176
184
  rows: number;
177
185
  }, admit?: () => string | null): Promise<TerminalSummary>;
178
- /** Attach a page: it gets the scrollback, then `hello`, then live output. Returns its detach. */
186
+ /**
187
+ * Attach a page: it gets the screen as it is now (ScreenMirror), then `hello`, then live output — what the program
188
+ * writes meanwhile is held and sent after the screen, in order. Returns its detach.
189
+ */
179
190
  attach(id: string, viewer: TerminalViewer): () => void;
180
191
  /** How many pages are attached now. */
181
192
  viewerCount(id: string): number;