toolroll 0.9.69 → 0.9.71

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 +67 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +184 -115
  5. package/dist/agent-fence.js +418 -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 +8 -11
  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 +25 -8
  32. package/dist/invoke.js +115 -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 +37 -3
  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 +109 -0
  45. package/dist/sandbox-runtime.js +281 -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,11 @@ 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
+ // With sign-ins, the keychain too (Claude Code keeps its macOS sign-in there), only while a person still set it.
1021
+ fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network,
1022
+ ...(signInsKeychain(store, checkout === null ? null : store.getWorktree(checkout)?.repo ?? null) ? { keychain: true } : {}) },
1023
+ confinement: "required",
1014
1024
  env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)), ...(signIns?.env ?? {}) },
1015
1025
  ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
1016
1026
  });
@@ -1030,14 +1040,14 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
1030
1040
  * project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
1031
1041
  * database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
1032
1042
  */
1033
- export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network) {
1043
+ export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable(), access = CHECK_ACCESS_DEFAULT, network, sandbox = LEGACY_SANDBOX) {
1034
1044
  if (inner !== runCommand)
1035
1045
  return inner;
1036
1046
  return async (file, args, options) => {
1037
1047
  if (!available()) {
1038
1048
  if (store.unfencedAgentsAllowed())
1039
1049
  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 };
1050
+ 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
1051
  }
1042
1052
  const databaseFile = store.databaseFile();
1043
1053
  if (databaseFile === null)
@@ -1057,15 +1067,16 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
1057
1067
  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
1068
  }
1059
1069
  const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
1060
- const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
1070
+ const boundary = taskBoundary({ worktree: checkout, provider: "command", binding, policy: sandbox.policy });
1061
1071
  const folders = runFolders(own);
1062
1072
  const cache = buildCacheFolder(repo);
1063
1073
  const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
1064
1074
  const database = options?.env?.["TOOLROLL_DB"];
1065
1075
  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])] } };
1076
+ const launch = fenceFor("check", { databaseFile, worktree: checkout, boundary, binding, runFolders: folders, poolRoots, extra: allowed?.deny ?? [], linked: linkedFor(store, checkout),
1077
+ readDeny: sandbox.readDeny.map(one => readDenyPath(one, homedir())), machLookup: sandbox.machLookup, policy: sandbox.policy,
1078
+ writes: allowed === null ? null : { writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] }, readOnly: allowed.readOnly } });
1079
+ const grants = launch.fenceGrants;
1069
1080
  // With sign-ins, under the network its caller proved for it (the narrowest of its results' sealed check networks and
1070
1081
  // the project's current one): none given, nothing runs.
1071
1082
  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 +1086,12 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
1075
1086
  try {
1076
1087
  return await inner(file, args, {
1077
1088
  ...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 },
1089
+ fence: launch.fence,
1090
+ // Each sign-in file looked at again right before the launch: one no longer itself isn't granted.
1091
+ // With sign-ins, the keychain too (Claude Code keeps its macOS sign-in there), only while a person still set it.
1092
+ fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network,
1093
+ ...(signInsKeychain(store, repo) ? { keychain: true } : {}) },
1094
+ confinement: launch.confinement,
1080
1095
  env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)), ...(signIns?.env ?? {}) },
1081
1096
  ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
1082
1097
  });
@@ -1156,80 +1171,40 @@ function plainLines(stdout) {
1156
1171
  * keyed to its default one; check-access.ts). gh's and Codex's are set to the granted ones instead. */
1157
1172
  const SIGN_INS_OMITTED_ENV = ["CLAUDE_CONFIG_DIR"];
1158
1173
  /**
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.
1174
+ * A `sign-ins` check's launch (check-access.ts, sandbox parity C10): its exact sign-in files, read by the check itself
1175
+ * through its sandbox (agent-fence.ts signInGrants: nothing privileged reads or copies one), its own transcript folder
1176
+ * and Codex's session and log folders; Codex, gh and XDG pointed at the real folders; and the network the check runs
1177
+ * under: open, or its allowlist through a proxy of its own (on Linux, relayed in by the sandbox runtime). Nothing about it is
1178
+ * started unless all of it is.
1164
1179
  */
1180
+ /**
1181
+ * Whether a check with sign-ins also reaches the keychain (macOS securityd: any item every program may read, not only
1182
+ * the sign-ins). Only while the project's sign-ins level stands as a person set it: widening to sign-ins takes an
1183
+ * instance operator's password typed now (operate.ts project check-access, Settings → Project → Check access), and the
1184
+ * one who set it must still be an approver for the project and an instance operator now. Anything else: no keychain.
1185
+ */
1186
+ export function signInsKeychain(store, repo) {
1187
+ if (repo === null || store.projectCheckAccess(repo) !== "sign-ins")
1188
+ return false;
1189
+ const change = store.projectCheckAccessChange(repo);
1190
+ const account = change === null ? null : store.accountOf(change.by);
1191
+ return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(change.by, repo) && store.isInstanceOperator(change.by);
1192
+ }
1165
1193
  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
1194
  if (!signInsAvailable(platform))
1169
- return { problem: "Sign-ins can't be shared with checks yet, so nothing ran." };
1195
+ return { problem: "Sign-ins can't be shared with checks here, so nothing ran." };
1170
1196
  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
- }
1197
+ const grants = signInGrants(home, checkout, platform);
1213
1198
  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 }); };
1199
+ const close = async () => { await egress?.close(); };
1217
1200
  try {
1218
- const env = { CODEX_HOME: codex, GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
1201
+ const env = { CODEX_HOME: join(home, ".codex"), GH_CONFIG_DIR: join(home, ".config", "gh"), XDG_CONFIG_HOME: join(home, ".config") };
1219
1202
  if (policy.mode === "open")
1220
1203
  return { grants, network: { mode: "open" }, env, close };
1221
1204
  // Exactly the project's network: the services the sign-ins are for are reached only if the operator allowed their
1222
1205
  // hosts (check-access.ts signInHostsMissing says which are missing; never added here).
1223
1206
  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
- }
1207
+ const network = { mode: "allowlist", proxyPort: egress.port, brokerPorts: [], loopback: policy.loopback, socketRoots: [temp, realpathSync(checkout)], token: egress.secret };
1233
1208
  return { grants, network, env: { ...env, ...proxyEnv(egress, policy.loopback) }, close };
1234
1209
  }
1235
1210
  catch (error) {
@@ -1270,6 +1245,31 @@ export function checkoutRunner(store, repo, checkout, inner, runIds, ran, platfo
1270
1245
  return (file, args, options) => {
1271
1246
  const launch = checkoutLaunchOf(store, repo, runIds, platform);
1272
1247
  ran(actualCheckAccess(store, launch.access));
1273
- return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network)(file, args, options);
1248
+ return fencedCheckoutCommands(store, repo, checkout, inner, undefined, launch.access, launch.network, checkoutSandboxOf(store, repo, runIds))(file, args, options);
1249
+ };
1250
+ }
1251
+ /**
1252
+ * A saved result's checks' sandbox terms (sandbox parity C9), never wider than any of those results' own approvals nor
1253
+ * the project now: v2 only when every result's approval sealed v2; every read deny any of them sealed or the project
1254
+ * lists now; mach services only those all of them sealed and the project still lists. A result with
1255
+ * no readable approval counts as v1 with nothing listed.
1256
+ */
1257
+ export function checkoutSandboxOf(store, repo, runIds) {
1258
+ const sealed = runIds.map(id => {
1259
+ const taskRef = store.getRun(id)?.taskRef;
1260
+ const ref = taskRef === undefined ? null : store.refForId(taskRef);
1261
+ const profile = ref === null ? null : store.getScope(ref.externalId)?.approvedProfile ?? null;
1262
+ return profile === null ? LEGACY_SANDBOX : launchSandboxOf(profile);
1263
+ });
1264
+ const live = store.projectSandbox(repo);
1265
+ const common = (pick, now) => now.filter(one => sealed.length > 0 && sealed.every(terms => pick(terms).includes(one)));
1266
+ return {
1267
+ policy: sealed.length > 0 && sealed.every(one => one.policy === 2) ? 2 : 1,
1268
+ readDeny: [...new Set([...live.readDeny, ...sealed.flatMap(one => one.readDeny)])].sort(),
1269
+ machLookup: common(one => one.machLookup, live.machLookup),
1274
1270
  };
1275
1271
  }
1272
+ /** The digest of a launch's whole fence (its denied paths and grants, canonical), as stamped on its run (C11). */
1273
+ export function fenceDigest(fence, grants) {
1274
+ return createHash("sha256").update(`toolroll:fence:${canonicalJson({ fence: [...fence], grants: grants })}`).digest("hex").slice(0, 32);
1275
+ }
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?: {