toolroll 0.9.69 → 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 (68) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +180 -115
  5. package/dist/agent-fence.js +413 -535
  6. package/dist/agentconfig.js +6 -1
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  8. package/dist/builder.js +24 -4
  9. package/dist/check-access.d.ts +6 -9
  10. package/dist/check-access.js +7 -10
  11. package/dist/coding-provider.d.ts +14 -0
  12. package/dist/coding-provider.js +33 -1
  13. package/dist/coding-workspace.d.ts +15 -0
  14. package/dist/coding-workspace.js +9 -2
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/doctor.d.ts +7 -0
  18. package/dist/doctor.js +4 -0
  19. package/dist/egress.d.ts +2 -0
  20. package/dist/egress.js +13 -4
  21. package/dist/exec.d.ts +17 -3
  22. package/dist/exec.js +54 -17
  23. package/dist/flow-actions.d.ts +1 -0
  24. package/dist/flow-actions.js +3 -2
  25. package/dist/flow-code.d.ts +31 -2
  26. package/dist/flow-code.js +57 -5
  27. package/dist/flow-steps.d.ts +4 -0
  28. package/dist/flow-steps.js +9 -5
  29. package/dist/flow-triggers.d.ts +4 -0
  30. package/dist/flow-triggers.js +8 -3
  31. package/dist/invoke.d.ts +18 -8
  32. package/dist/invoke.js +98 -115
  33. package/dist/operate.js +46 -0
  34. package/dist/project-delete-ui.d.ts +7 -6
  35. package/dist/project-delete-ui.js +36 -2
  36. package/dist/project-sandbox.d.ts +59 -0
  37. package/dist/project-sandbox.js +83 -0
  38. package/dist/project-tools.d.ts +29 -5
  39. package/dist/project-tools.js +43 -7
  40. package/dist/release.d.ts +1 -1
  41. package/dist/release.js +1 -0
  42. package/dist/sandbox-launch.d.ts +1 -0
  43. package/dist/sandbox-launch.js +148 -0
  44. package/dist/sandbox-runtime.d.ts +62 -0
  45. package/dist/sandbox-runtime.js +140 -0
  46. package/dist/scope.d.ts +28 -0
  47. package/dist/scope.js +25 -5
  48. package/dist/scout-browser-lock.d.ts +69 -0
  49. package/dist/scout-browser-lock.js +69 -0
  50. package/dist/scout-browser.d.ts +60 -1
  51. package/dist/scout-browser.js +200 -4
  52. package/dist/scout.d.ts +9 -4
  53. package/dist/scout.js +14 -4
  54. package/dist/serve.js +3 -1
  55. package/dist/server/render-tasks.js +4 -1
  56. package/dist/server/route-table.js +1 -0
  57. package/dist/server/settings.js +27 -0
  58. package/dist/store.d.ts +31 -1
  59. package/dist/store.js +83 -4
  60. package/dist/subagent-tools.d.ts +1 -0
  61. package/dist/subagent-tools.js +7 -5
  62. package/dist/tool-broker.d.ts +2 -1
  63. package/dist/tool-broker.js +2 -2
  64. package/dist/tool-launcher.js +10 -16
  65. package/package.json +2 -1
  66. package/scripts/provider-canary.mjs +5 -6
  67. package/dist/egress-bridge.d.ts +0 -17
  68. package/dist/egress-bridge.js +0 -111
package/dist/invoke.js CHANGED
@@ -29,15 +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 { createHash } from "node:crypto";
32
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, checkLaunchRoot, readGrantedFile, SIGN_IN_COPIES_DIR, signInCopiesRoot, signInGrants } from "./agent-fence.js";
37
+ import { checkLaunchRoot, signInGrants, signInIsItself } from "./agent-fence.js";
37
38
  import { CHECK_ACCESS_DEFAULT, checkAccessHere, narrowestAccess, narrowestNetwork, signInsAvailable } from "./check-access.js";
38
- 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";
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";
39
43
  import { proxyEnv, providerOnlyNetwork, repositoryEcosystems, resolveNetwork, startEgressProxy } from "./egress.js";
40
- import { bridgeFits, startEgressBridge } from "./egress-bridge.js";
41
44
  import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
42
45
  import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
43
46
  /** "Within seconds": how soon a silent failed run must end for its stderr to
@@ -242,7 +245,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
242
245
  // re-supply survives resolveChildEnv's chat-key strip); in
243
246
  // "subscription" mode the key is STRIPPED from the child so the CLI's
244
247
  // own login is used — its key is retained, just not handed over.
245
- 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;
246
249
  // The live setting is read STRICTLY (atomic authority closure): a
247
250
  // present mode file that says neither word is a value-shaped refusal
248
251
  // before any stamp or process — never the subscription default a
@@ -305,6 +308,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
305
308
  // (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
306
309
  // Set once the fence is known: a fenced build's servers never start outside a fence.
307
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();
308
314
  // What the build writes (its checkout, its project's caches): a tool server never runs from there, nor reads it at all.
309
315
  const serverUntrusted = workspace === null ? [] : toolUntrusted(store, workspace, keyHome);
310
316
  const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
@@ -316,7 +322,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
316
322
  untrusted: serverUntrusted,
317
323
  wrap: () => serverFence,
318
324
  // A fenced build's tool servers start only in a fence too, unless an instance operator allowed unconfined agents.
319
- unwrapped: () => serverMustBeFenced && !store.unfencedAgentsAllowed()
325
+ unwrapped: () => serverMustBeFenced && !unconfinedAtLaunch
320
326
  ? "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." : null,
321
327
  });
322
328
  if (launchTools !== null && "refused" in launchTools) {
@@ -345,7 +351,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
345
351
  // tool servers outside its command sandbox, so its own launch folder is
346
352
  // fenced too; Claude reads its config from there, inside the sandbox.
347
353
  const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
348
- 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);
349
355
  if (fenceOf.problem !== undefined) {
350
356
  tools?.cleanup();
351
357
  codexHome?.cleanup();
@@ -386,13 +392,13 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
386
392
  if (fence.length > 0) {
387
393
  // A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network,
388
394
  // nor its write allowlist (a server keeps its own state where it always has).
389
- const serverGrants = () => { const { network: _agentOnly, writes: _agentWrites, ...rest } = grants; return rest; };
395
+ const serverGrants = () => serverGrantsOf(grants);
390
396
  // Nor read what the build wrote: however its arguments are spelled, no byte of the checkout or caches can load.
391
397
  const serverDenies = [...fence, ...serverUntrusted.filter(path => existsSync(path)).map(path => realpathSync(path))];
392
- if (macosFenceAvailable())
393
- serverFence = (file, args) => macosFenced(file, args, serverDenies, serverGrants());
394
- else if (linuxFenceAvailable())
395
- 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);
396
402
  serverMustBeFenced = true;
397
403
  }
398
404
  // No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
@@ -404,10 +410,9 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
404
410
  codexHome?.cleanup();
405
411
  removeAgentDatabase(isolatedDb.dir);
406
412
  return { kind: "refused", reason: "containment", providerVersion: attested?.version ?? null,
407
- 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`." };
408
414
  }
409
415
  let egress = null;
410
- let bridge = null;
411
416
  let runTemp = null;
412
417
  const confinedOs = fenced.method === "macos-sandbox" || fenced.method === "linux-bubblewrap";
413
418
  if (fence.length > 0 && !codexHarness && confinedOs) {
@@ -429,22 +434,19 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
429
434
  }
430
435
  else {
431
436
  egress = await startEgressProxy({ hosts: network.policy.hosts });
432
- 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 };
433
438
  }
434
- if (fenced.method === "linux-bubblewrap" && grants.network.mode === "allowlist") {
435
- const database = store.databaseFile();
436
- // The plane's own fenced folder, unless a socket there would be too long a path (a unix socket's path is capped
437
- // near 100 bytes): then the system temp folder, which a confined Linux build doesn't see (its /tmp is its own).
438
- const fenced = database === null || database === "" || database === ":memory:" ? undefined : join(dirname(realpathSync(database)), BRIDGE_DIR);
439
- bridge = await startEgressBridge([grants.network.proxyPort, ...grants.network.brokerPorts], fenced !== undefined && bridgeFits(fenced) ? fenced : undefined);
440
- grants.network.bridge = { folder: realpathSync(bridge.folder), forwarder: realpathSync(bridge.forwarder), node: process.execPath };
441
- // Its private /tmp gives back only its own folders: its checkout and its launch folder (its temp is inside).
442
- 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 });
443
446
  }
444
447
  }
445
448
  catch (error) {
446
449
  await egress?.close();
447
- await bridge?.close();
448
450
  tools?.cleanup();
449
451
  codexHome?.cleanup();
450
452
  removeAgentDatabase(isolatedDb.dir);
@@ -452,8 +454,11 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
452
454
  diagnostic: `The build's network could not be set up, so nothing was started: ${error instanceof Error ? error.message : String(error)}` };
453
455
  }
454
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.
455
459
  if (invocation.phase !== "review")
456
- 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());
457
462
  const billingSeen = { keySource: null, model: null, planWindows: false, answered: false };
458
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),
459
464
  // read right before it runs. Never with an injected runner: tests don't start a real Codex.
@@ -496,7 +501,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
496
501
  : ownKeyEnv.filter(name => name !== keyEnvName)),
497
502
  ];
498
503
  const launchOptions = {
499
- ...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants }),
504
+ ...(fenced.wrap.length === 0 ? {} : { fence: fenced.wrap, fenceGrants: grants, confinement: "required" }),
500
505
  ...runOptions,
501
506
  ...(workspace === null ? {} : { cwd: workspace }),
502
507
  // v105: how the CLI really billed this turn (its key source, its plan's windows), read off its own stream.
@@ -566,7 +571,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
566
571
  const insideFence = (file, args) => codexHarness
567
572
  ? runCommand(executable, ["sandbox", ...codexFenceArgv(fence, invocation.skipPermissions, grants),
568
573
  "-P", "standing-orders", "--", file, ...args], probeOptions)
569
- : runCommand(file, args, { ...probeOptions, fence: launchOptions.fence,
574
+ : runCommand(file, args, { ...probeOptions, fence: launchOptions.fence, confinement: "required",
570
575
  ...(launchOptions.fenceGrants === undefined ? {} : { fenceGrants: launchOptions.fenceGrants }) });
571
576
  const check = await insideFence("git", gitArgs);
572
577
  if (check.code !== 0) {
@@ -582,7 +587,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
582
587
  // key in the launching environment must be absent. No agent takes part, so no agent can fake it.
583
588
  if (acpCheckAsk?.isolation !== undefined && launchOptions.fence !== undefined && workspace !== null) {
584
589
  // processGroup: run() applies the fence only on the process-group road (a plain execFile carries none).
585
- 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 };
586
591
  const read = await runCommand("/bin/cat", [acpCheckAsk.isolation.file], probe);
587
592
  const env = await runCommand("/usr/bin/env", [], probe);
588
593
  acpIsolation = {
@@ -705,7 +710,6 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
705
710
  }, clock());
706
711
  }
707
712
  await egress?.close();
708
- await bridge?.close();
709
713
  }
710
714
  // v105: the run's billing, fixed from evidence before its usage is priced: a key it was given is a key; otherwise
711
715
  // what the CLI said (Claude's stream) or was last seen doing (Codex's account), and the setting only when neither.
@@ -926,7 +930,7 @@ function linkedFor(store, worktree) {
926
930
  /** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
927
931
  * ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
928
932
  * (FenceGrants); and what only Claude's file tools refuse. */
929
- function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT) {
933
+ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT, sandbox = LEGACY_SANDBOX) {
930
934
  try {
931
935
  // A plane always has a database file; an in-memory store (a test) has nothing to fence.
932
936
  const databaseFile = store.databaseFile();
@@ -939,22 +943,24 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
939
943
  throw new CheckoutBindingError("The checkout's Git pointer was changed or its trusted binding could not be verified.");
940
944
  const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
941
945
  const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
942
- const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
946
+ const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding, policy: sandbox.policy });
943
947
  const folders = runFolders(ownTools);
944
948
  // A build with a checkout writes only its workspace unless its approval said anywhere (arc 2): its checkout and
945
949
  // Git data, its launch folder, its project's caches and the provider state its CLI must write.
946
950
  const allowed = worktree === null || row === null || writes !== "workspace" ? null
947
951
  : writeAllowlist({ provider, home: keyHome ?? homedir(), worktree, binding, launchFolder: ownTools, cache: buildCacheFolder(row.repo, keyHome ?? homedir()) });
948
- const fence = agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] });
949
- 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 });
950
959
  return {
951
- fence,
960
+ fence: launch.fence,
952
961
  fileTools: boundary?.fileTools ?? [],
953
- // The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
954
- // Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
955
- // 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.
956
- grants: allowed === null ? grants : { ...grants, writes: allowed.writes, readOnly: [...new Set([...grants.readOnly, ...allowed.readOnly])].sort() },
957
- // 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).
958
964
  gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
959
965
  ...(allowed === null || row === null ? {} : { cache: buildCacheFolder(row.repo, keyHome ?? homedir()) }),
960
966
  };
@@ -970,7 +976,7 @@ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEF
970
976
  * Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
971
977
  * (a checkout whose binding no longer proves) runs nothing.
972
978
  */
973
- export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network) {
979
+ export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
974
980
  // An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
975
981
  if (inner !== runCommand)
976
982
  return inner;
@@ -982,7 +988,7 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
982
988
  store.stampRunCheckAccess(runId, "unsandboxed");
983
989
  return inner(file, args, options);
984
990
  }
985
- 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 };
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 };
986
992
  }
987
993
  const own = realpathSync(mkdtempSync(join(checkLaunchRoot(), "so-cmd-")));
988
994
  try {
@@ -991,7 +997,7 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
991
997
  // Not sandboxed (check-access.ts): the project's checks are trusted by the whole instance, as before 0.9.66.
992
998
  if (access === "unsandboxed")
993
999
  return await inner(file, args, { ...options, env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp } });
994
- const fenced = runFence(store, runId, "command", undefined, own, writes);
1000
+ const fenced = runFence(store, runId, "command", undefined, own, writes, sandbox);
995
1001
  if (fenced.problem !== undefined)
996
1002
  return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
997
1003
  if (fenced.fence.length === 0)
@@ -1010,7 +1016,9 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
1010
1016
  return await inner(file, args, {
1011
1017
  ...options,
1012
1018
  fence: fenced.fence,
1013
- fenceGrants: signIns === null ? grants : { ...grants, signIns: signIns.grants, network: signIns.network },
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",
1014
1022
  env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)), ...(signIns?.env ?? {}) },
1015
1023
  ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
1016
1024
  });
@@ -1030,14 +1038,14 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
1030
1038
  * project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
1031
1039
  * database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
1032
1040
  */
1033
- export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network) {
1041
+ export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
1034
1042
  if (inner !== runCommand)
1035
1043
  return inner;
1036
1044
  return async (file, args, options) => {
1037
1045
  if (!available()) {
1038
1046
  if (store.unfencedAgentsAllowed())
1039
1047
  return inner(file, args, options);
1040
- 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 };
1041
1049
  }
1042
1050
  const databaseFile = store.databaseFile();
1043
1051
  if (databaseFile === null)
@@ -1057,15 +1065,16 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
1057
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 };
1058
1066
  }
1059
1067
  const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
1060
- const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
1068
+ const boundary = taskBoundary({ worktree: checkout, provider: "command", binding, policy: sandbox.policy });
1061
1069
  const folders = runFolders(own);
1062
1070
  const cache = buildCacheFolder(repo);
1063
1071
  const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
1064
1072
  const database = options?.env?.["TOOLROLL_DB"];
1065
1073
  const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
1066
- const base = fenceGrants({ databaseFile, worktree: checkout, boundary, runFolders: folders, linked: linkedFor(store, checkout), binding, poolRoots });
1067
- const grants = allowed === null ? base
1068
- : { ...base, readOnly: [...new Set([...base.readOnly, ...allowed.readOnly])].sort(), writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] } };
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;
1069
1078
  // With sign-ins, under the network its caller proved for it (the narrowest of its results' sealed check networks and
1070
1079
  // the project's current one): none given, nothing runs.
1071
1080
  const signIns = access !== "sign-ins" ? null : network === undefined ? { problem: "A check with sign-ins has no network sealed for it, so nothing ran." }
@@ -1075,8 +1084,10 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
1075
1084
  try {
1076
1085
  return await inner(file, args, {
1077
1086
  ...options,
1078
- fence: agentFence({ databaseFile, worktree: checkout, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] }),
1079
- fenceGrants: signIns === null ? grants : { ...grants, signIns: signIns.grants, network: signIns.network },
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,
1080
1091
  env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)), ...(signIns?.env ?? {}) },
1081
1092
  ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
1082
1093
  });
@@ -1156,80 +1167,27 @@ function plainLines(stdout) {
1156
1167
  * keyed to its default one; check-access.ts). gh's and Codex's are set to the granted ones instead. */
1157
1168
  const SIGN_INS_OMITTED_ENV = ["CLAUDE_CONFIG_DIR"];
1158
1169
  /**
1159
- * A `sign-ins` check's launch (check-access.ts): its exact sign-in files and its own transcript folder
1160
- * (agent-fence.ts signInGrants), Codex's config and sign-in copied into a home of the launch's own (private, gone with
1161
- * the launch; ~/.codex itself stays fenced), gh pointed at its own folder, and the network the check runs under:
1162
- * open, or its allowlist through a proxy of its own (on Linux, carried in by a bridge). Nothing about it is started
1163
- * unless all of it is.
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.
1164
1175
  */
1165
1176
  export async function signInsLaunch(store, own, temp, checkout, policy, gitPaths, platform = process.platform) {
1166
- // The belt (check-access.ts signInsAvailable, off for now): no sign-in file is read, copied or granted, whatever level
1167
- // reached this far. Its callers already run such a check sandboxed; anything else that asks runs nothing.
1168
1177
  if (!signInsAvailable(platform))
1169
- return { problem: "Sign-ins can't be shared with checks yet, so nothing ran." };
1178
+ return { problem: "Sign-ins can't be shared with checks here, so nothing ran." };
1170
1179
  const home = homedir();
1171
- const grants = signInGrants(home, checkout);
1172
- const cleanup = [];
1173
- // Codex's files are copied, each read only as the real file it is (readGrantedFile: no link, here or above it).
1174
- const codex = join(own, "codex");
1175
- mkdirSync(codex, { mode: 0o700 });
1176
- for (const name of ["config.toml", "auth.json"]) {
1177
- const bytes = readGrantedFile(join(realpathSync(home), ".codex", name));
1178
- if (bytes !== null)
1179
- writeFileSync(join(codex, name), bytes, { mode: 0o600 });
1180
- }
1181
- // On Linux every granted file is bound from a copy of its own, made the same way (bubblewrap binds a source path, and
1182
- // a link swapped in after validation must never be what it binds). A Mac's sandbox checks the real path itself.
1183
- // Not reached for now: the belt above refuses until reading these files is race-free.
1184
- if (process.platform === "linux") {
1185
- const copies = {};
1186
- // Under Toolroll's own state folder, which every fence denies whole (a launch's folder in a shared /tmp isn't masked
1187
- // from a launch already running): bubblewrap binds from the original root, so only this check sees them.
1188
- const database = store.databaseFile();
1189
- if (database === null || database === "" || database === ":memory:")
1190
- return { problem: "A check with sign-ins needs Toolroll's state folder, so nothing ran." };
1191
- const state = dirname(realpathSync(database));
1192
- try {
1193
- mkdirSync(join(state, SIGN_IN_COPIES_DIR), { mode: 0o700 });
1194
- }
1195
- catch { /* there already: checked next */ }
1196
- // Only the real, private folder Toolroll made (this user's, 0700, no link): never a link planted where it should be.
1197
- const parent = signInCopiesRoot(state);
1198
- if (parent === null)
1199
- return { problem: "The sign-in copies folder isn't a private folder of Toolroll's, so nothing ran." };
1200
- const folder = mkdtempSync(join(parent, `c-${process.pid}-`));
1201
- cleanup.push(folder);
1202
- grants.read.forEach((path, index) => {
1203
- const bytes = readGrantedFile(path);
1204
- if (bytes === null)
1205
- return;
1206
- const copy = join(folder, String(index));
1207
- writeFileSync(copy, bytes, { mode: 0o600 });
1208
- copies[path] = copy;
1209
- });
1210
- grants.read = grants.read.filter(path => copies[path] !== undefined);
1211
- grants.copies = copies;
1212
- }
1180
+ const grants = signInGrants(home, checkout, platform);
1213
1181
  let egress = null;
1214
- let bridge = null;
1215
- const close = async () => { await egress?.close(); await bridge?.close(); for (const one of cleanup)
1216
- rmSync(one, { recursive: true, force: true }); };
1182
+ const close = async () => { await egress?.close(); };
1217
1183
  try {
1218
- const env = { CODEX_HOME: codex, GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
1184
+ const env = { CODEX_HOME: join(home, ".codex"), GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
1219
1185
  if (policy.mode === "open")
1220
1186
  return { grants, network: { mode: "open" }, env, close };
1221
1187
  // Exactly the project's network: the services the sign-ins are for are reached only if the operator allowed their
1222
1188
  // hosts (check-access.ts signInHostsMissing says which are missing; never added here).
1223
1189
  egress = await startEgressProxy({ hosts: policy.hosts });
1224
- const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: policy.loopback, socketRoots: [temp, realpathSync(checkout)] };
1225
- if (linuxFenceAvailable()) {
1226
- const database = store.databaseFile();
1227
- const fenced = database === null || database === "" || database === ":memory:" ? undefined : join(dirname(realpathSync(database)), BRIDGE_DIR);
1228
- bridge = await startEgressBridge([egress.port], fenced !== undefined && bridgeFits(fenced) ? fenced : undefined);
1229
- network.bridge = { folder: realpathSync(bridge.folder), forwarder: realpathSync(bridge.forwarder), node: process.execPath };
1230
- // Its private /tmp gives back its checkout, its launch folder and its checkout's own Git data (as a build's does).
1231
- network.keep = [realpathSync(checkout), own, ...gitPaths.filter(path => existsSync(path))];
1232
- }
1190
+ const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: policy.loopback, socketRoots: [temp, realpathSync(checkout)], token: egress.secret };
1233
1191
  return { grants, network, env: { ...env, ...proxyEnv(egress, policy.loopback) }, close };
1234
1192
  }
1235
1193
  catch (error) {
@@ -1270,6 +1228,31 @@ export function checkoutRunner(store, repo, checkout, inner, runIds, ran, platfo
1270
1228
  return (file, args, options) => {
1271
1229
  const launch = checkoutLaunchOf(store, repo, runIds, platform);
1272
1230
  ran(actualCheckAccess(store, launch.access));
1273
- return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network)(file, args, options);
1231
+ return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network, checkoutSandboxOf(store, repo, runIds))(file, args, options);
1274
1232
  };
1275
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.js CHANGED
@@ -113,6 +113,7 @@ import { parseLeadToken } from "./api-tokens.js";
113
113
  import { MAX_NETWORK_HOSTS, normalizeHost } from "./egress.js";
114
114
  import { isWritesMode, writesWords } from "./writes-mode.js";
115
115
  import { linuxFenceAvailable, macosFenceAvailable } from "./agent-fence.js";
116
+ import { parseMachLookup, parseReadDeny, readDenyWords } from "./project-sandbox.js";
116
117
  import { CHECK_ACCESS_WARNINGS, checkAccessRunsAs, checkAccessWords, isCheckAccess, signInHostsMissing, signInsUnavailableWords, widerThan } from "./check-access.js";
117
118
  import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
118
119
  import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
@@ -691,6 +692,8 @@ export const OPERATE_VALUE_FLAGS = new Set([
691
692
  "token-name",
692
693
  // task pause / task steer --now: the asker's own request id, so a repeat is the same request.
693
694
  "request",
695
+ // project sandbox (sandbox parity C9): read denies and macOS services.
696
+ "read-deny", "mach",
694
697
  ]);
695
698
  export const OPERATE_BOOLEAN_FLAGS = new Set([
696
699
  "json", "yes", "all", "brief", "local", "history", "latest-watch", "dry-run", "file",
@@ -1180,6 +1183,8 @@ async function dispatch(command, positional, flags, context) {
1180
1183
  return projectWritesCommand(positional, flags, context);
1181
1184
  if (positional[0] === "check-access")
1182
1185
  return projectCheckAccessCommand(positional, flags, context);
1186
+ if (positional[0] === "sandbox")
1187
+ return projectSandboxCommand(positional, flags, context);
1183
1188
  if (positional[0] === "checks")
1184
1189
  return projectChecksCommand(positional, flags, context);
1185
1190
  if (positional[0] === "demo")
@@ -11640,3 +11645,44 @@ async function projectCheckAccessCommand(positional, flags, context) {
11640
11645
  "Scopes written from now on use this; scopes already approved keep what they were approved with.",
11641
11646
  ]);
11642
11647
  }
11648
+ /**
11649
+ * `project sandbox --repo <p> [--read-deny <a,b>] [--mach <a,b>]` (project-sandbox.ts, sandbox parity C9): what the
11650
+ * project's builds and checks may not read (paths, `~/…` or absolute) and the macOS services they may also use. Each list is replaced whole by its flag (an empty value clears it). New scopes seal it;
11651
+ * approved ones keep theirs. Changing it is an approver's act with a password, kept in the ledger as before → after.
11652
+ */
11653
+ async function projectSandboxCommand(positional, flags, context) {
11654
+ const command = "project sandbox";
11655
+ const allowed = new Set(["repo", "read-deny", "mach", "as", "token", "token-file", "token-env", "db", "json"]);
11656
+ for (const name of flags.keys())
11657
+ if (!allowed.has(name))
11658
+ return fail(context.write, context.json, command, "usage", `--${name} is not a project sandbox option.`, EXIT.usage);
11659
+ const repoFlag = text(flags, "repo");
11660
+ if (positional.length > 1 || repoFlag === undefined)
11661
+ return fail(context.write, context.json, command, "usage", "Use project sandbox --repo <project path> [--read-deny <paths>] [--mach <services>] (comma-separated; empty clears).", EXIT.usage);
11662
+ const repo = await knownProject(context, repoFlag, { listed: true, canonical: true });
11663
+ if (repo === undefined)
11664
+ return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
11665
+ const words = (one) => [`${repo}: ${readDenyWords(one.readDeny)}`,
11666
+ ...(one.machLookup.length === 0 ? [] : [`macOS services also allowed: ${one.machLookup.join(", ")}`])];
11667
+ const current = context.store.projectSandbox(repo);
11668
+ const asked = (name) => { const value = flags.get(name); return value === undefined ? undefined : value === true ? [] : value.split(","); };
11669
+ const changes = { readDeny: asked("read-deny"), machLookup: asked("mach") };
11670
+ if (changes.readDeny === undefined && changes.machLookup === undefined)
11671
+ return succeed(context.write, context.json, command, { repo, sandbox: current }, () => words(current));
11672
+ const parsed = { readDeny: parseReadDeny(changes.readDeny ?? current.readDeny), machLookup: parseMachLookup(changes.machLookup ?? current.machLookup) };
11673
+ for (const [name, one] of Object.entries(parsed))
11674
+ if (!one.ok)
11675
+ return fail(context.write, context.json, command, "usage", `${name}: ${one.problem}. Nothing changed.`, EXIT.usage);
11676
+ const wanted = { readDeny: parsed.readDeny.ok ? parsed.readDeny.list : [], machLookup: parsed.machLookup.ok ? parsed.machLookup.list : [] };
11677
+ const acting = await askCredentials(flags, context);
11678
+ const verified = acting === null ? null : credentialApprover(context.store, acting.name, acting.token, repo);
11679
+ if (acting === null || !verified?.ok || !context.store.accountCanAccess(acting.name, repo))
11680
+ return fail(context.write, context.json, command, "refused", "An approver for this project changes its sandbox, with their password: pass --as and --token.", EXIT.refused);
11681
+ const changed = context.store.setProjectSandbox(repo, wanted, acting.name, context.clock());
11682
+ if (changed === null)
11683
+ return fail(context.write, context.json, command, "refused", "An approver for this project changes its sandbox. Nothing changed.", EXIT.refused);
11684
+ return succeed(context.write, context.json, command, { repo, before: changed.before, sandbox: changed.after }, () => [
11685
+ ...words(changed.after),
11686
+ "Scopes written from now on use this; scopes already approved keep what they were approved with.",
11687
+ ]);
11688
+ }
@@ -1,9 +1,4 @@
1
- /**
2
- * Settings → Project: what Toolroll holds for a project, and deleting
3
- * it. An instance operator deletes in two steps: type the project's name,
4
- * then read exactly what goes and confirm with their password (or a fresh
5
- * sign-in with the identity provider). The repository itself stays.
6
- */
1
+ import type { ProjectSandbox } from "./project-sandbox.js";
7
2
  import type { ProjectNetwork } from "./egress.js";
8
3
  import type { WritesMode } from "./writes-mode.js";
9
4
  import { type CheckAccess } from "./check-access.js";
@@ -51,6 +46,12 @@ export type ProjectSettingsView = {
51
46
  setting: WritesMode;
52
47
  canChange: boolean;
53
48
  };
49
+ /** What its builds and checks may not read, and the macOS services and protected files they may still use
50
+ * (project-sandbox.ts), and whether this person may change it. */
51
+ sandbox?: {
52
+ setting: ProjectSandbox;
53
+ canChange: boolean;
54
+ };
54
55
  /** How far its checks reach (check-access.ts): the level, who changed it last, and whether this person may narrow it
55
56
  * (an approver for the project) or also widen it (an instance operator). */
56
57
  checkAccess?: {
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Settings → Project: what Toolroll holds for a project, and deleting
3
+ * it. An instance operator deletes in two steps: type the project's name,
4
+ * then read exactly what goes and confirm with their password (or a fresh
5
+ * sign-in with the identity provider). The repository itself stays.
6
+ */
7
+ import { sandboxRuntimeWords } from "./sandbox-runtime.js";
1
8
  import { CHECK_ACCESS_WARNINGS, signInHostsMissing } from "./check-access.js";
2
9
  import { html, postForm, reauth } from "./html.js";
3
10
  import { holdingsWords } from "./project-delete.js";
@@ -49,6 +56,33 @@ function writesHtml(view) {
49
56
  <button type="submit">Save</button>`, { hidden: { repo: view.repo } });
50
57
  return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<details><summary>Change</summary>${form}</details></section>`;
51
58
  }
59
+ /** Home folder (project-sandbox.ts): what builds and checks can read there, said once; the paths they can't, and the
60
+ * advanced list (macOS services), changed under Change with the approver's password. */
61
+ function sandboxHtml(view) {
62
+ const sandbox = view.sandbox;
63
+ if (sandbox === undefined)
64
+ return "";
65
+ const { readDeny, machLookup } = sandbox.setting;
66
+ const said = readDeny.length === 0
67
+ ? html `<p>Builds and checks can read your home folder, as in Anthropic's sandbox runtime. Toolroll's own folders stay out of reach; other tasks' work is read, never written.</p>`
68
+ : html `<p>Builds and checks can read your home folder except <strong>${readDeny.length === 1 ? readDeny[0] : `${readDeny.length} paths`}</strong>.</p>`;
69
+ const extra = machLookup.length === 0 ? "" : html `<p class="meta">${machLookup.length} macOS ${machLookup.length === 1 ? "service" : "services"} allowed</p>`;
70
+ // The engine, named where the sandbox is set (sandbox-runtime.ts).
71
+ const engine = html `<p class="meta" data-sandbox-engine>Builds, checks and flows run in ${sandboxRuntimeWords()}.</p>`;
72
+ const head = html `<h2 id="sandbox-title">Home folder</h2>${said}${extra}${engine}`;
73
+ if (!sandbox.canChange)
74
+ return html `<section class="build-access sandbox-setting" id="sandbox" aria-labelledby="sandbox-title">${head}<p class="meta">An approver for this project can change this.</p></section>`;
75
+ const form = postForm("/settings/project/sandbox", html `
76
+ <label>Paths builds can't read<textarea name="read-deny" rows="4" placeholder="~/.ssh&#10;~/.aws">${readDeny.join("\n")}</textarea></label>
77
+ <p class="meta">One per line: <code>~/…</code> or a full path.</p>
78
+ <details><summary>Advanced</summary>
79
+ <label>macOS services builds may also use<textarea name="mach" rows="2">${machLookup.join("\n")}</textarea></label>
80
+ </details>
81
+ <p class="meta">Tasks scoped from now on use this; tasks already approved keep what they were approved with.</p>
82
+ ${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}
83
+ <button type="submit">Save</button>`, { hidden: { repo: view.repo } });
84
+ return html `<section class="build-access sandbox-setting" id="sandbox" aria-labelledby="sandbox-title">${head}<details><summary>Change</summary>${form}</details></section>`;
85
+ }
52
86
  /** Check access (check-access.ts): how far the project's checks reach, said once. Collapsed: the level, what it means
53
87
  * when it isn't the default, and who changed it. Inside Change: a line per level, and each wider level's warning under it
54
88
  * only while it is chosen. An instance operator widens it with a password typed now; an approver narrows it. */
@@ -83,7 +117,7 @@ function checkAccessHtml(view) {
83
117
  };
84
118
  const open = network?.mode === "open";
85
119
  const form = postForm("/settings/project/check-access", html `
86
- ${option("sandboxed", "Can't read the files your sign-ins and credentials are kept in, Toolroll's folders or other projects.", null)}
120
+ ${option("sandboxed", "Reads your home folder, as in Anthropic's sandbox runtime, except the folders this project hides (Home folder, above). Never Toolroll's folders or other tasks' work.", null)}
87
121
  ${unavailable !== null ? option("sign-ins", unavailable, null) : option("sign-ins", `Also reads the files your Claude, Codex and GitHub sign-ins are kept in, on this project's network (${open ? "any host" : network === null || network.hosts.length === 0 ? "registries only" : `registries and ${network.hosts.length} ${network.hosts.length === 1 ? "host" : "hosts"}`}).`, CHECK_ACCESS_WARNINGS["sign-ins"])}
88
122
  ${option("unsandboxed", "For projects whose checks create sandboxes themselves, such as Toolroll's own.", CHECK_ACCESS_WARNINGS.unsandboxed)}
89
123
  ${open && unavailable === null ? html `<label class="check"><input type="checkbox" name="confirm-open" value="yes"> The network is open: with sign-ins, checks could send them to any host</label>` : ""}
@@ -170,7 +204,7 @@ export function projectSettingsHtml(view, notice) {
170
204
  const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
171
205
  ? html `<p class="path">${view.remoteUrl}</p>`
172
206
  : html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add &lt;project&gt; --remote &lt;url&gt;</p></details></section>`;
173
- const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
207
+ const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${sandboxHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
174
208
  if (!view.canDelete)
175
209
  return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
176
210
  const body = view.running.length > 0