toolroll 0.9.68 → 0.9.70

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 (78) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +204 -81
  5. package/dist/agent-fence.js +467 -397
  6. package/dist/agentconfig.d.ts +1 -1
  7. package/dist/agentconfig.js +11 -2
  8. package/dist/batch-checks.js +14 -26
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  10. package/dist/builder.d.ts +9 -0
  11. package/dist/builder.js +50 -4
  12. package/dist/check-access.d.ts +51 -0
  13. package/dist/check-access.js +77 -0
  14. package/dist/coding-provider.d.ts +14 -0
  15. package/dist/coding-provider.js +33 -1
  16. package/dist/coding-workspace.d.ts +15 -0
  17. package/dist/coding-workspace.js +9 -2
  18. package/dist/contracts/console-api.d.ts +2 -0
  19. package/dist/contracts/console-api.js +2 -0
  20. package/dist/doctor.d.ts +7 -0
  21. package/dist/doctor.js +4 -0
  22. package/dist/egress.d.ts +2 -0
  23. package/dist/egress.js +13 -4
  24. package/dist/exec.d.ts +17 -3
  25. package/dist/exec.js +54 -17
  26. package/dist/flow-actions.d.ts +1 -0
  27. package/dist/flow-actions.js +3 -2
  28. package/dist/flow-code.d.ts +31 -2
  29. package/dist/flow-code.js +57 -5
  30. package/dist/flow-steps.d.ts +4 -0
  31. package/dist/flow-steps.js +9 -5
  32. package/dist/flow-triggers.d.ts +4 -0
  33. package/dist/flow-triggers.js +8 -3
  34. package/dist/invoke.d.ts +50 -3
  35. package/dist/invoke.js +204 -66
  36. package/dist/operate.d.ts +4 -0
  37. package/dist/operate.js +119 -0
  38. package/dist/project-delete-ui.d.ts +23 -6
  39. package/dist/project-delete-ui.js +94 -1
  40. package/dist/project-sandbox.d.ts +59 -0
  41. package/dist/project-sandbox.js +83 -0
  42. package/dist/project-tools.d.ts +29 -5
  43. package/dist/project-tools.js +43 -7
  44. package/dist/release.d.ts +1 -1
  45. package/dist/release.js +1 -0
  46. package/dist/result-follow-ups.js +10 -15
  47. package/dist/sandbox-launch.d.ts +1 -0
  48. package/dist/sandbox-launch.js +148 -0
  49. package/dist/sandbox-runtime.d.ts +62 -0
  50. package/dist/sandbox-runtime.js +140 -0
  51. package/dist/scope.d.ts +37 -0
  52. package/dist/scope.js +32 -5
  53. package/dist/scout-browser-lock.d.ts +69 -0
  54. package/dist/scout-browser-lock.js +69 -0
  55. package/dist/scout-browser.d.ts +60 -1
  56. package/dist/scout-browser.js +200 -4
  57. package/dist/scout.d.ts +9 -4
  58. package/dist/scout.js +14 -4
  59. package/dist/serve.js +12 -1
  60. package/dist/server/http.d.ts +2 -0
  61. package/dist/server/render-results.d.ts +3 -0
  62. package/dist/server/render-results.js +1 -0
  63. package/dist/server/render-tasks.js +5 -1
  64. package/dist/server/route-table.js +2 -0
  65. package/dist/server/settings.js +64 -0
  66. package/dist/server/tasks.js +2 -0
  67. package/dist/store.d.ts +59 -2
  68. package/dist/store.js +166 -4
  69. package/dist/subagent-tools.d.ts +1 -0
  70. package/dist/subagent-tools.js +7 -5
  71. package/dist/surface.js +2 -1
  72. package/dist/tool-broker.d.ts +2 -1
  73. package/dist/tool-broker.js +2 -2
  74. package/dist/tool-launcher.js +10 -16
  75. package/package.json +2 -1
  76. package/scripts/provider-canary.mjs +5 -6
  77. package/dist/egress-bridge.d.ts +0 -17
  78. package/dist/egress-bridge.js +0 -111
package/dist/invoke.js CHANGED
@@ -29,14 +29,18 @@ 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 { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
32
+ import { createHash } from "node:crypto";
33
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
33
34
  import { homedir, tmpdir } from "node:os";
34
35
  import { dirname, join } from "node:path";
35
36
  import { poolRootOf } from "./worktree.js";
36
- import { BRIDGE_DIR } 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
- import { proxyEnv, providerOnlyNetwork, startEgressProxy } from "./egress.js";
39
- import { bridgeFits, startEgressBridge } from "./egress-bridge.js";
37
+ import { checkLaunchRoot, signInGrants, signInIsItself } from "./agent-fence.js";
38
+ import { CHECK_ACCESS_DEFAULT, checkAccessHere, narrowestAccess, narrowestNetwork, signInsAvailable } from "./check-access.js";
39
+ import { readDenyPath } from "./project-sandbox.js";
40
+ import { sandboxRuntimeWords } from "./sandbox-runtime.js";
41
+ import { canonicalJson } from "./canonical-json.js";
42
+ import { buildCacheEnv, buildCacheFolder, grantFolder, writeAllowlist, WRITES_DEFAULT, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceFor, fencedGitEnv, LEGACY_SANDBOX, launchSandboxOf, linuxFenceAvailable, macosFenceAvailable, NO_GRANTS, sandboxedSpawn, serverGrantsOf, runFolders, taskBoundary } from "./agent-fence.js";
43
+ import { proxyEnv, providerOnlyNetwork, repositoryEcosystems, resolveNetwork, startEgressProxy } from "./egress.js";
40
44
  import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
41
45
  import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
42
46
  /** "Within seconds": how soon a silent failed run must end for its stderr to
@@ -241,7 +245,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
241
245
  // re-supply survives resolveChildEnv's chat-key strip); in
242
246
  // "subscription" mode the key is STRIPPED from the child so the CLI's
243
247
  // own login is used — its key is retained, just not handed over.
244
- 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;
248
+ const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, writes: launchWrites, sandbox: launchSandbox, ...runOptions } = options;
245
249
  // The live setting is read STRICTLY (atomic authority closure): a
246
250
  // present mode file that says neither word is a value-shaped refusal
247
251
  // before any stamp or process — never the subscription default a
@@ -304,6 +308,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
304
308
  // (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
305
309
  // Set once the fence is known: a fenced build's servers never start outside a fence.
306
310
  let serverMustBeFenced = false;
311
+ // Pinned for the attempt (sandbox parity C11): whether an instance operator allowed unconfined agents, read once now.
312
+ // A tool server the agent starts late gets the decision this attempt launched with, never one changed meanwhile.
313
+ const unconfinedAtLaunch = store.unfencedAgentsAllowed();
307
314
  // What the build writes (its checkout, its project's caches): a tool server never runs from there, nor reads it at all.
308
315
  const serverUntrusted = workspace === null ? [] : toolUntrusted(store, workspace, keyHome);
309
316
  const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
@@ -315,7 +322,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
315
322
  untrusted: serverUntrusted,
316
323
  wrap: () => serverFence,
317
324
  // A fenced build's tool servers start only in a fence too, unless an instance operator allowed unconfined agents.
318
- unwrapped: () => serverMustBeFenced && !store.unfencedAgentsAllowed()
325
+ unwrapped: () => serverMustBeFenced && !unconfinedAtLaunch
319
326
  ? "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." : null,
320
327
  });
321
328
  if (launchTools !== null && "refused" in launchTools) {
@@ -344,7 +351,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
344
351
  // tool servers outside its command sandbox, so its own launch folder is
345
352
  // fenced too; Claude reads its config from there, inside the sandbox.
346
353
  const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
347
- 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);
354
+ 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, launchSandbox ?? LEGACY_SANDBOX);
348
355
  if (fenceOf.problem !== undefined) {
349
356
  tools?.cleanup();
350
357
  codexHome?.cleanup();
@@ -385,13 +392,13 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
385
392
  if (fence.length > 0) {
386
393
  // A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network,
387
394
  // nor its write allowlist (a server keeps its own state where it always has).
388
- const serverGrants = () => { const { network: _agentOnly, writes: _agentWrites, ...rest } = grants; return rest; };
395
+ const serverGrants = () => serverGrantsOf(grants);
389
396
  // Nor read what the build wrote: however its arguments are spelled, no byte of the checkout or caches can load.
390
397
  const serverDenies = [...fence, ...serverUntrusted.filter(path => existsSync(path)).map(path => realpathSync(path))];
391
- if (macosFenceAvailable())
392
- serverFence = (file, args) => macosFenced(file, args, serverDenies, serverGrants());
393
- else if (linuxFenceAvailable())
394
- serverFence = (file, args) => linuxFenced(file, args, serverDenies, serverGrants());
398
+ // Pinned: the server fence this attempt launched with, a copy, whatever changes later (C11).
399
+ const pinned = structuredClone(serverGrants());
400
+ if (macosFenceAvailable() || linuxFenceAvailable())
401
+ serverFence = (file, args, cwd) => sandboxedSpawn(file, args, serverDenies, pinned, cwd);
395
402
  serverMustBeFenced = true;
396
403
  }
397
404
  // No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
@@ -403,10 +410,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
403
410
  codexHome?.cleanup();
404
411
  removeAgentDatabase(isolatedDb.dir);
405
412
  return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
406
- diagnostic: "This machine can't confine an agent (no macOS sandbox, no working bubblewrap), so nothing was started. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`." };
413
+ diagnostic: "This machine can't confine an agent (no macOS sandbox, no working bubblewrap), so nothing was started. Install bubblewrap, socat and ripgrep (Linux), or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`." };
407
414
  }
408
415
  let egress = null;
409
- let bridge = null;
410
416
  let runTemp = null;
411
417
  const confinedOs = fenced.method === "macos-sandbox" || fenced.method === "linux-bubblewrap";
412
418
  if (fence.length > 0 && !codexHarness && confinedOs) {
@@ -428,22 +434,19 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
428
434
  }
429
435
  else {
430
436
  egress = await startEgressProxy({ hosts: network.policy.hosts });
431
- grants.network = { mode: "allowlist", proxyPort: egress.port, brokerPorts, loopback: network.policy.loopback, socketRoots };
437
+ grants.network = { mode: "allowlist", proxyPort: egress.port, brokerPorts, loopback: network.policy.loopback, socketRoots, token: egress.secret };
432
438
  }
433
- if (fenced.method === "linux-bubblewrap" && grants.network.mode === "allowlist") {
434
- const database = store.databaseFile();
435
- // The plane's own fenced folder, unless a socket there would be too long a path (a unix socket's path is capped
436
- // near 100 bytes): then the system temp folder, which a confined Linux build doesn't see (its /tmp is its own).
437
- const fenced = database === null || database === "" || database === ":memory:" ? undefined : join(dirname(realpathSync(database)), BRIDGE_DIR);
438
- bridge = await startEgressBridge([grants.network.proxyPort, ...grants.network.brokerPorts], fenced !== undefined && bridgeFits(fenced) ? fenced : undefined);
439
- grants.network.bridge = { folder: realpathSync(bridge.folder), forwarder: realpathSync(bridge.forwarder), node: process.execPath };
440
- // Its private /tmp gives back only its own folders: its checkout and its launch folder (its temp is inside).
441
- grants.network.keep = [...(workspace === null ? [] : [realpathSync(workspace)]), ...(ownTools === null ? [] : [ownTools]), ...(fenceOf.gitPaths ?? [])];
439
+ // On Linux the build has a network of its own, and the sandbox runtime relays only two ports into it: the
440
+ // proxy's (3128) and a second (1080), which carries this run's tool broker. Claude's tool list names it there.
441
+ // (An injected runner, in tests, runs outside every sandbox and keeps the broker's own address.)
442
+ if (fenced.method === "linux-bubblewrap" && grants.network.mode === "allowlist" && runner === undefined && tools?.brokerPort !== undefined && tools.privateDir !== undefined) {
443
+ const list = join(tools.privateDir, "mcp.json");
444
+ if (existsSync(list))
445
+ writeFileSync(list, readFileSync(list, "utf8").replaceAll(`http://127.0.0.1:${tools.brokerPort}/`, "http://127.0.0.1:1080/"), { mode: 0o600 });
442
446
  }
443
447
  }
444
448
  catch (error) {
445
449
  await egress?.close();
446
- await bridge?.close();
447
450
  tools?.cleanup();
448
451
  codexHome?.cleanup();
449
452
  removeAgentDatabase(isolatedDb.dir);
@@ -451,8 +454,11 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
451
454
  diagnostic: `The build's network could not be set up, so nothing was started: ${error instanceof Error ? error.message : String(error)}` };
452
455
  }
453
456
  }
457
+ // The attempt's pinned fence, stamped on the run (C11): its method, policy version, the digest of exactly what it was
458
+ // given, and the sandbox engine and version it runs in.
454
459
  if (invocation.phase !== "review")
455
- store.recordRunFence(runId, { method: fenced.method, paths: fence.length }, clock());
460
+ store.recordRunFence(runId, { method: fenced.method, paths: fence.length, policy: (launchSandbox ?? LEGACY_SANDBOX).policy,
461
+ digest: fenceDigest(fence, grants), engine: sandboxRuntimeWords() }, clock());
456
462
  const billingSeen = { keySource: null, model: null, planWindows: false, answered: false };
457
463
  // v105: a Codex run on its sign-in bills as Codex says it's signed in (a ChatGPT plan, or a saved key, or Bedrock),
458
464
  // read right before it runs. Never with an injected runner: tests don't start a real Codex.
@@ -495,7 +501,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
495
501
  : ownKeyEnv.filter(name => name !== keyEnvName)),
496
502
  ];
497
503
  const launchOptions = {
498
- ...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants }),
504
+ ...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants, confinement: "required" }),
499
505
  ...runOptions,
500
506
  ...(workspace === null ? {} : { cwd: workspace }),
501
507
  // v105: how the CLI really billed this turn (its key source, its plan's windows), read off its own stream.
@@ -565,7 +571,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
565
571
  const insideFence = (file, args) => codexHarness
566
572
  ? runCommand(executable, ["sandbox", ...codexFenceArgv(fence, invocation.skipPermissions, grants),
567
573
  "-P", "standing-orders", "--", file, ...args], probeOptions)
568
- : runCommand(file, args, { ...probeOptions, fence: launchOptions.fence,
574
+ : runCommand(file, args, { ...probeOptions, fence: launchOptions.fence, confinement: "required",
569
575
  ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }) });
570
576
  const check = await insideFence("git", gitArgs);
571
577
  if (check.code !== 0) {
@@ -581,7 +587,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
581
587
  // key in the launching environment must be absent. No agent takes part, so no agent can fake it.
582
588
  if (acpCheckAsk?.isolation !== undefined && launchOptions.fence !== undefined && workspace !== null) {
583
589
  // processGroup: run() applies the fence only on the process-group road (a plain execFile carries none).
584
- const probe = { cwd: workspace, env: childEnv, omitEnv: childOmitEnv, fence: launchOptions.fence, ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }), timeoutMs: 15_000, processGroup: true };
590
+ const probe = { cwd: workspace, env: childEnv, omitEnv: childOmitEnv, fence: launchOptions.fence, confinement: "required", ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }), timeoutMs: 15_000, processGroup: true };
585
591
  const read = await runCommand("/bin/cat", [acpCheckAsk.isolation.file], probe);
586
592
  const env = await runCommand("/usr/bin/env", [], probe);
587
593
  acpIsolation = {
@@ -704,7 +710,6 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
704
710
  }, clock());
705
711
  }
706
712
  await egress?.close();
707
- await bridge?.close();
708
713
  }
709
714
  // v105: the run's billing, fixed from evidence before its usage is priced: a key it was given is a key; otherwise
710
715
  // what the CLI said (Claude's stream) or was last seen doing (Codex's account), and the setting only when neither.
@@ -925,7 +930,7 @@ function linkedFor(store, worktree) {
925
930
  /** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
926
931
  * ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
927
932
  * (FenceGrants); and what only Claude's file tools refuse. */
928
- function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT) {
933
+ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT, sandbox = LEGACY_SANDBOX) {
929
934
  try {
930
935
  // A plane always has a database file; an in-memory store (a test) has nothing to fence.
931
936
  const databaseFile = store.databaseFile();
@@ -938,22 +943,24 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
938
943
  throw new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
939
944
  const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
940
945
  const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
941
- const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
946
+ const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding, policy: sandbox.policy });
942
947
  const folders = runFolders(ownTools);
943
948
  // A build with a checkout writes only its workspace unless its approval said anywhere (arc 2): its checkout and
944
949
  // Git data, its launch folder, its project's caches and the provider state its CLI must write.
945
950
  const allowed = worktree === null || row === null || writes !== "workspace" ? null
946
951
  : writeAllowlist({ provider, home: keyHome ?? homedir(), worktree, binding, launchFolder: ownTools, cache: buildCacheFolder(row.repo, keyHome ?? homedir()) });
947
- const fence = agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] });
948
- const grants = fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home, poolRoots });
952
+ // The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
953
+ // Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
954
+ // 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.
955
+ const launch = fenceFor(provider === "command" ? "command" : "build", { databaseFile, worktree, ...home, boundary, binding, runFolders: folders, poolRoots,
956
+ extra: allowed?.deny ?? [], linked: linkedFor(store, worktree), writes: allowed === null ? null : { writes: allowed.writes, readOnly: allowed.readOnly }, policy: sandbox.policy, provider,
957
+ // The sealed read denies, and whatever the project lists now too (adding a deny only narrows).
958
+ readDeny: [...new Set([...sandbox.readDeny, ...(row === null ? [] : store.projectSandbox(row.repo).readDeny)])].map(one => readDenyPath(one, keyHome ?? homedir())), machLookup: sandbox.machLookup });
949
959
  return {
950
- fence,
960
+ fence: launch.fence,
951
961
  fileTools: boundary?.fileTools ?? [],
952
- // The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
953
- // Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
954
- // 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.
955
- grants: allowed === null ? grants : { ...grants, writes: allowed.writes, readOnly: [...new Set([...grants.readOnly, ...allowed.readOnly])].sort() },
956
- // The checkout's own Git data, which a private /tmp gives back beside the checkout (agent-fence.ts linuxFenced).
962
+ grants: launch.fenceGrants,
963
+ // The checkout's own Git data (a sign-ins check's network keeps it reachable).
957
964
  gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
958
965
  ...(allowed === null || row === null ? {} : { cache: buildCacheFolder(row.repo, keyHome ?? homedir()) }),
959
966
  };
@@ -969,22 +976,28 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
969
976
  * Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
970
977
  * (a checkout whose binding no longer proves) runs nothing.
971
978
  */
972
- export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
979
+ export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
973
980
  // An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
974
981
  if (inner !== runCommand)
975
982
  return inner;
976
983
  return async (file, args, options) => {
977
- // As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents.
984
+ // As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents
985
+ // (and the run says so: its checks ran without a sandbox, whatever was sealed).
978
986
  if (!available()) {
979
- if (store.unfencedAgentsAllowed())
987
+ if (store.unfencedAgentsAllowed()) {
988
+ store.stampRunCheckAccess(runId, "unsandboxed");
980
989
  return inner(file, args, options);
981
- 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 };
990
+ }
991
+ 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, socat and ripgrep (Linux), or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
982
992
  }
983
- const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
993
+ const own = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-cmd-")));
984
994
  try {
985
995
  const temp = join(own, "tmp");
986
996
  mkdirSync(temp, { mode: 0o700 });
987
- const fenced = runFence(store, runId, "command", undefined, own, writes);
997
+ // Not sandboxed (check-access.ts): the project's checks are trusted by the whole instance, as before 0.9.66.
998
+ if (access === "unsandboxed")
999
+ return await inner(file, args, { ...options, env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp } });
1000
+ const fenced = runFence(store, runId, "command", undefined, own, writes, sandbox);
988
1001
  if (fenced.problem !== undefined)
989
1002
  return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
990
1003
  if (fenced.fence.length === 0)
@@ -993,12 +1006,26 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
993
1006
  const database = options?.env?.["TOOLROLL_DB"];
994
1007
  const checkData = database === undefined || fenced.grants.writes === undefined ? null : grantFolder(dirname(database), false);
995
1008
  const grants = checkData === null ? fenced.grants : { ...fenced.grants, writes: { ...fenced.grants.writes, subpaths: [...fenced.grants.writes.subpaths, checkData] } };
996
- return await inner(file, args, {
997
- ...options,
998
- fence: fenced.fence,
999
- fenceGrants: grants,
1000
- env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)) },
1001
- });
1009
+ const checkout = store.getRun(runId)?.worktree ?? null;
1010
+ const signIns = access !== "sign-ins" ? null : checkout === null ? { problem: "The check's checkout is unknown, so its sign-ins can't be granted." }
1011
+ : network === undefined ? { problem: "A check with sign-ins has no network sealed for it, so nothing ran." }
1012
+ : await signInsLaunch(store, own, temp, checkout, network, fenced.gitPaths ?? []);
1013
+ if (signIns !== null && "problem" in signIns)
1014
+ return { code: 1, stdout: "", stderr: `${signIns.problem}\n`, timedOut: false, notFound: false };
1015
+ try {
1016
+ return await inner(file, args, {
1017
+ ...options,
1018
+ fence: fenced.fence,
1019
+ // Each sign-in file looked at again right before the launch: one no longer itself isn't granted.
1020
+ fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network },
1021
+ confinement: "required",
1022
+ env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)), ...(signIns?.env ?? {}) },
1023
+ ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
1024
+ });
1025
+ }
1026
+ finally {
1027
+ await signIns?.close();
1028
+ }
1002
1029
  }
1003
1030
  finally {
1004
1031
  rmSync(own, { recursive: true, force: true });
@@ -1011,22 +1038,25 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
1011
1038
  * project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
1012
1039
  * database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
1013
1040
  */
1014
- export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
1041
+ export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
1015
1042
  if (inner !== runCommand)
1016
1043
  return inner;
1017
1044
  return async (file, args, options) => {
1018
1045
  if (!available()) {
1019
1046
  if (store.unfencedAgentsAllowed())
1020
1047
  return inner(file, args, options);
1021
- 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 };
1048
+ 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, socat and ripgrep (Linux), or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
1022
1049
  }
1023
1050
  const databaseFile = store.databaseFile();
1024
1051
  if (databaseFile === null)
1025
1052
  return inner(file, args, options);
1026
- const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
1053
+ const own = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-cmd-")));
1027
1054
  try {
1028
1055
  const temp = join(own, "tmp");
1029
1056
  mkdirSync(temp, { mode: 0o700 });
1057
+ // Not sandboxed (check-access.ts): the project's checks are trusted by the whole instance, as before 0.9.66.
1058
+ if (access === "unsandboxed")
1059
+ return await inner(file, args, { ...options, env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp } });
1030
1060
  let binding;
1031
1061
  try {
1032
1062
  binding = trustedWorktreeBinding(repo, checkout);
@@ -1035,21 +1065,36 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
1035
1065
  return { code: 1, stdout: "", stderr: "The check's checkout couldn't be bound to its project, so nothing ran.\n", timedOut: false, notFound: false };
1036
1066
  }
1037
1067
  const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
1038
- const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
1068
+ const boundary = taskBoundary({ worktree: checkout, provider: "command", binding, policy: sandbox.policy });
1039
1069
  const folders = runFolders(own);
1040
1070
  const cache = buildCacheFolder(repo);
1041
1071
  const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
1042
1072
  const database = options?.env?.["TOOLROLL_DB"];
1043
1073
  const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
1044
- const base = fenceGrants({ databaseFile, worktree: checkout, boundary, runFolders: folders, linked: linkedFor(store, checkout), binding, poolRoots });
1045
- const grants = allowed === null ? base
1046
- : { ...base, readOnly: [...new Set([...base.readOnly, ...allowed.readOnly])].sort(), writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] } };
1047
- return await inner(file, args, {
1048
- ...options,
1049
- fence: agentFence({ databaseFile, worktree: checkout, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] }),
1050
- fenceGrants: grants,
1051
- env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)) },
1052
- });
1074
+ const launch = fenceFor("check", { databaseFile, worktree: checkout, boundary, binding, runFolders: folders, poolRoots, extra: allowed?.deny ?? [], linked: linkedFor(store, checkout),
1075
+ readDeny: sandbox.readDeny.map(one => readDenyPath(one, homedir())), machLookup: sandbox.machLookup, policy: sandbox.policy,
1076
+ writes: allowed === null ? null : { writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] }, readOnly: allowed.readOnly } });
1077
+ const grants = launch.fenceGrants;
1078
+ // With sign-ins, under the network its caller proved for it (the narrowest of its results' sealed check networks and
1079
+ // the project's current one): none given, nothing runs.
1080
+ const signIns = access !== "sign-ins" ? null : network === undefined ? { problem: "A check with sign-ins has no network sealed for it, so nothing ran." }
1081
+ : await signInsLaunch(store, own, temp, checkout, network, [binding.gitdir, join(binding.main, ".git")]);
1082
+ if (signIns !== null && "problem" in signIns)
1083
+ return { code: 1, stdout: "", stderr: `${signIns.problem}\n`, timedOut: false, notFound: false };
1084
+ try {
1085
+ return await inner(file, args, {
1086
+ ...options,
1087
+ fence: launch.fence,
1088
+ // Each sign-in file looked at again right before the launch: one no longer itself isn't granted.
1089
+ fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network },
1090
+ confinement: launch.confinement,
1091
+ env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)), ...(signIns?.env ?? {}) },
1092
+ ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
1093
+ });
1094
+ }
1095
+ finally {
1096
+ await signIns?.close();
1097
+ }
1053
1098
  }
1054
1099
  finally {
1055
1100
  rmSync(own, { recursive: true, force: true });
@@ -1118,3 +1163,96 @@ async function codexLoginBilling(binary, omitEnv) {
1118
1163
  function plainLines(stdout) {
1119
1164
  return stdout.split("\n").filter(line => !line.trimStart().startsWith("{")).join("\n");
1120
1165
  }
1166
+ /** What a `sign-ins` check never inherits: a caller's choice of Claude's config folder (Claude's keychain sign-in is
1167
+ * keyed to its default one; check-access.ts). gh's and Codex's are set to the granted ones instead. */
1168
+ const SIGN_INS_OMITTED_ENV = ["CLAUDE_CONFIG_DIR"];
1169
+ /**
1170
+ * A `sign-ins` check's launch (check-access.ts, sandbox parity C10): its exact sign-in files, read by the check itself
1171
+ * through its sandbox (agent-fence.ts signInGrants: nothing privileged reads or copies one), its own transcript folder
1172
+ * and Codex's session and log folders; Codex, gh and XDG pointed at the real folders; and the network the check runs
1173
+ * under: open, or its allowlist through a proxy of its own (on Linux, relayed in by the sandbox runtime). Nothing about it is
1174
+ * started unless all of it is.
1175
+ */
1176
+ export async function signInsLaunch(store, own, temp, checkout, policy, gitPaths, platform = process.platform) {
1177
+ if (!signInsAvailable(platform))
1178
+ return { problem: "Sign-ins can't be shared with checks here, so nothing ran." };
1179
+ const home = homedir();
1180
+ const grants = signInGrants(home, checkout, platform);
1181
+ let egress = null;
1182
+ const close = async () => { await egress?.close(); };
1183
+ try {
1184
+ const env = { CODEX_HOME: join(home, ".codex"), GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
1185
+ if (policy.mode === "open")
1186
+ return { grants, network: { mode: "open" }, env, close };
1187
+ // Exactly the project's network: the services the sign-ins are for are reached only if the operator allowed their
1188
+ // hosts (check-access.ts signInHostsMissing says which are missing; never added here).
1189
+ egress = await startEgressProxy({ hosts: policy.hosts });
1190
+ const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: policy.loopback, socketRoots: [temp, realpathSync(checkout)], token: egress.secret };
1191
+ return { grants, network, env: { ...env, ...proxyEnv(egress, policy.loopback) }, close };
1192
+ }
1193
+ catch (error) {
1194
+ await close();
1195
+ return { problem: `The check's network could not be set up, so nothing ran: ${error instanceof Error ? error.message : String(error)}` };
1196
+ }
1197
+ }
1198
+ /** How a check at `access` actually runs here: unsandboxed where no OS fence exists (and an operator allowed that), else
1199
+ * as asked. Recorded on every check's receipt, so a check that ran without a sandbox is never shown as sandboxed. */
1200
+ export function actualCheckAccess(store, access) {
1201
+ return macosFenceAvailable() || linuxFenceAvailable() || !store.unfencedAgentsAllowed() ? access : "unsandboxed";
1202
+ }
1203
+ /**
1204
+ * A check of saved results, in their own checkout (batch-checks.ts, result-follow-ups.ts), worked out right before each
1205
+ * command: never wider than any of those results' own checks ran, nor than the project allows now; under sign-ins, the
1206
+ * narrowest of their sealed check networks and the project's current one. A result with no sealed network runs it
1207
+ * sandboxed.
1208
+ */
1209
+ export function checkoutLaunchOf(store, repo, runIds, platform = process.platform) {
1210
+ // As checks can get it: sign-ins (off for now; a stamp or setting from before, or another database) run sandboxed.
1211
+ const access = checkAccessHere(narrowestAccess([store.projectCheckAccess(repo), ...runIds.map(id => store.runCheckAccess(id))]), platform);
1212
+ if (access !== "sign-ins")
1213
+ return { access, network: undefined };
1214
+ const sealed = runIds.map(id => store.runCheckNetwork(id));
1215
+ if (sealed.length === 0 || sealed.some(one => one === null))
1216
+ return { access: CHECK_ACCESS_DEFAULT, network: undefined };
1217
+ const live = resolveNetwork("command", store.projectNetwork(repo), repositoryEcosystems(repo));
1218
+ return { access, network: sealed.reduce((low, one) => narrowestNetwork(low, one), live) };
1219
+ }
1220
+ /** The widest level a check's commands actually ran at (what its receipt says). */
1221
+ export const widestRan = (levels) => levels.includes("unsandboxed") ? "unsandboxed" : levels.includes("sign-ins") ? "sign-ins" : CHECK_ACCESS_DEFAULT;
1222
+ /** A check log's line for how its commands ran, when wider than sandboxed (shown with the log). */
1223
+ export const ranAtLine = (level) => level === "sandboxed" ? null
1224
+ : `=== Check access ===\n${level === "unsandboxed" ? "These commands ran without a sandbox." : "These commands ran sandboxed, with model and GitHub sign-ins."}`;
1225
+ /** A saved result's check command runner (batch-checks.ts, result-follow-ups.ts): its level and network worked out right
1226
+ * now (checkoutLaunchOf), and how it actually runs told to `ran`. */
1227
+ export function checkoutRunner(store, repo, checkout, inner, runIds, ran, platform = process.platform) {
1228
+ return (file, args, options) => {
1229
+ const launch = checkoutLaunchOf(store, repo, runIds, platform);
1230
+ ran(actualCheckAccess(store, launch.access));
1231
+ return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network, checkoutSandboxOf(store, repo, runIds))(file, args, options);
1232
+ };
1233
+ }
1234
+ /**
1235
+ * A saved result's checks' sandbox terms (sandbox parity C9), never wider than any of those results' own approvals nor
1236
+ * the project now: v2 only when every result's approval sealed v2; every read deny any of them sealed or the project
1237
+ * lists now; mach services only those all of them sealed and the project still lists. A result with
1238
+ * no readable approval counts as v1 with nothing listed.
1239
+ */
1240
+ export function checkoutSandboxOf(store, repo, runIds) {
1241
+ const sealed = runIds.map(id => {
1242
+ const taskRef = store.getRun(id)?.taskRef;
1243
+ const ref = taskRef === undefined ? null : store.refForId(taskRef);
1244
+ const profile = ref === null ? null : store.getScope(ref.externalId)?.approvedProfile ?? null;
1245
+ return profile === null ? LEGACY_SANDBOX : launchSandboxOf(profile);
1246
+ });
1247
+ const live = store.projectSandbox(repo);
1248
+ const common = (pick, now) => now.filter(one => sealed.length > 0 && sealed.every(terms => pick(terms).includes(one)));
1249
+ return {
1250
+ policy: sealed.length > 0 && sealed.every(one => one.policy === 2) ? 2 : 1,
1251
+ readDeny: [...new Set([...live.readDeny, ...sealed.flatMap(one => one.readDeny)])].sort(),
1252
+ machLookup: common(one => one.machLookup, live.machLookup),
1253
+ };
1254
+ }
1255
+ /** The digest of a launch's whole fence (its denied paths and grants, canonical), as stamped on its run (C11). */
1256
+ export function fenceDigest(fence, grants) {
1257
+ return createHash("sha256").update(`toolroll:fence:${canonicalJson({ fence: [...fence], grants: grants })}`).digest("hex").slice(0, 32);
1258
+ }
package/dist/operate.d.ts CHANGED
@@ -67,6 +67,10 @@ export type OperateOptions = {
67
67
  flowStepIo?: Partial<StepIo>;
68
68
  /** Injected by tests: how often a worker's flow passes run beside its builds (default FLOW_EVERY_MS). */
69
69
  flowEveryMs?: number;
70
+ /** Injected by tests: the operating system this runs on (check access: no sign-ins on Linux yet). */
71
+ platform?: NodeJS.Platform;
72
+ /** Injected by tests: whether this machine has an OS fence for checks (macOS sandbox or bubblewrap). */
73
+ fenceAvailable?: () => boolean;
70
74
  /** Injected by tests: how `flows import` fetches a flow file's address. */
71
75
  flowFetch?: FetchLike;
72
76
  /** Injected by tests: how `integrations` checks reach services (fetch, gh, sign-in checks, mail servers). */