toolroll 0.9.68 → 0.9.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +204 -81
  5. package/dist/agent-fence.js +467 -397
  6. package/dist/agentconfig.d.ts +1 -1
  7. package/dist/agentconfig.js +11 -2
  8. package/dist/batch-checks.js +14 -26
  9. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  10. package/dist/builder.d.ts +9 -0
  11. package/dist/builder.js +50 -4
  12. package/dist/check-access.d.ts +51 -0
  13. package/dist/check-access.js +77 -0
  14. package/dist/coding-provider.d.ts +14 -0
  15. package/dist/coding-provider.js +33 -1
  16. package/dist/coding-workspace.d.ts +15 -0
  17. package/dist/coding-workspace.js +9 -2
  18. package/dist/contracts/console-api.d.ts +2 -0
  19. package/dist/contracts/console-api.js +2 -0
  20. package/dist/doctor.d.ts +7 -0
  21. package/dist/doctor.js +4 -0
  22. package/dist/egress.d.ts +2 -0
  23. package/dist/egress.js +13 -4
  24. package/dist/exec.d.ts +17 -3
  25. package/dist/exec.js +54 -17
  26. package/dist/flow-actions.d.ts +1 -0
  27. package/dist/flow-actions.js +3 -2
  28. package/dist/flow-code.d.ts +31 -2
  29. package/dist/flow-code.js +57 -5
  30. package/dist/flow-steps.d.ts +4 -0
  31. package/dist/flow-steps.js +9 -5
  32. package/dist/flow-triggers.d.ts +4 -0
  33. package/dist/flow-triggers.js +8 -3
  34. package/dist/invoke.d.ts +50 -3
  35. package/dist/invoke.js +204 -66
  36. package/dist/operate.d.ts +4 -0
  37. package/dist/operate.js +119 -0
  38. package/dist/project-delete-ui.d.ts +23 -6
  39. package/dist/project-delete-ui.js +94 -1
  40. package/dist/project-sandbox.d.ts +59 -0
  41. package/dist/project-sandbox.js +83 -0
  42. package/dist/project-tools.d.ts +29 -5
  43. package/dist/project-tools.js +43 -7
  44. package/dist/release.d.ts +1 -1
  45. package/dist/release.js +1 -0
  46. package/dist/result-follow-ups.js +10 -15
  47. package/dist/sandbox-launch.d.ts +1 -0
  48. package/dist/sandbox-launch.js +148 -0
  49. package/dist/sandbox-runtime.d.ts +62 -0
  50. package/dist/sandbox-runtime.js +140 -0
  51. package/dist/scope.d.ts +37 -0
  52. package/dist/scope.js +32 -5
  53. package/dist/scout-browser-lock.d.ts +69 -0
  54. package/dist/scout-browser-lock.js +69 -0
  55. package/dist/scout-browser.d.ts +60 -1
  56. package/dist/scout-browser.js +200 -4
  57. package/dist/scout.d.ts +9 -4
  58. package/dist/scout.js +14 -4
  59. package/dist/serve.js +12 -1
  60. package/dist/server/http.d.ts +2 -0
  61. package/dist/server/render-results.d.ts +3 -0
  62. package/dist/server/render-results.js +1 -0
  63. package/dist/server/render-tasks.js +5 -1
  64. package/dist/server/route-table.js +2 -0
  65. package/dist/server/settings.js +64 -0
  66. package/dist/server/tasks.js +2 -0
  67. package/dist/store.d.ts +59 -2
  68. package/dist/store.js +166 -4
  69. package/dist/subagent-tools.d.ts +1 -0
  70. package/dist/subagent-tools.js +7 -5
  71. package/dist/surface.js +2 -1
  72. package/dist/tool-broker.d.ts +2 -1
  73. package/dist/tool-broker.js +2 -2
  74. package/dist/tool-launcher.js +10 -16
  75. package/package.json +2 -1
  76. package/scripts/provider-canary.mjs +5 -6
  77. package/dist/egress-bridge.d.ts +0 -17
  78. package/dist/egress-bridge.js +0 -111
@@ -65,7 +65,7 @@ export type ProfileResolution = {
65
65
  export declare function resolveScopeProfile(store: Store, repo: string | null, ref: Pick<TaskRef, "agentProvider" | "agentModel"> | undefined, flags: PhaseFlags & {
66
66
  repairModel?: string | undefined;
67
67
  permissionMode?: UnattendedPermissionMode | undefined;
68
- }): ProfileResolution;
68
+ }, platform?: NodeJS.Platform): ProfileResolution;
69
69
  /**
70
70
  * The two candidate tiers the routing policy chooses between, per phase,
71
71
  * read from configuration ONLY — never inferred from a model's name:
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import { PROJECT_NETWORK_DEFAULT, repositoryEcosystems, resolveNetwork } from "./egress.js";
23
23
  import { WRITES_DEFAULT } from "./writes-mode.js";
24
+ import { checkAccessHere } from "./check-access.js";
25
+ import { PROJECT_SANDBOX_DEFAULT } from "./project-sandbox.js";
24
26
  import { isProviderId, validateSpec } from "./provider.js";
25
27
  import { ACP_LIMITS, CLAUDE_LIMITS, CODEX_SHAPED_LIMITS, GEMINI_LIMITS } from "./scope.js";
26
28
  import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
@@ -102,7 +104,7 @@ ref) {
102
104
  const valid = validateSpec(spec);
103
105
  return valid.ok ? { ok: true, spec, source: flags.model !== undefined ? "flag" : source } : { ok: false, problem: valid.problem };
104
106
  }
105
- export function resolveScopeProfile(store, repo, ref, flags) {
107
+ export function resolveScopeProfile(store, repo, ref, flags, platform = process.platform) {
106
108
  const build = resolvePhaseAgent(store, "build", repo, flags, ref);
107
109
  if (!build.ok)
108
110
  return { ok: false, reason: "unknown-provider", problem: build.problem };
@@ -199,7 +201,14 @@ export function resolveScopeProfile(store, repo, ref, flags) {
199
201
  const network = resolveNetwork(provider, repo === null ? PROJECT_NETWORK_DEFAULT : store.projectNetwork(repo), repo === null ? [] : repositoryEcosystems(repo));
200
202
  // Where the build may write (writes-mode.ts): the project's setting, sealed likewise.
201
203
  const writes = repo === null ? WRITES_DEFAULT : store.projectWrites(repo);
202
- return { ok: true, profile: { ...profile, network, writes }, provenance: { resolvedFrom: build.source, repairFrom } };
204
+ // How far its checks reach (check-access.ts): the project's setting as checks can get it (sign-ins, off for now,
205
+ // seal as sandboxed: an approval never claims what its checks won't get), sealed only when wider than sandboxed.
206
+ const checkAccess = repo === null ? "sandboxed" : checkAccessHere(store.projectCheckAccess(repo), platform);
207
+ // The fence policy (sandbox parity, docs/plans/sandbox-parity.md §1.5): every new scope is v2; and the project's
208
+ // sandbox settings (project-sandbox.ts), sealed when it lists any.
209
+ const sandbox = repo === null ? PROJECT_SANDBOX_DEFAULT : store.projectSandbox(repo);
210
+ return { ok: true, profile: { ...profile, network, writes, ...(checkAccess === "sandboxed" ? {} : { checkAccess }), fencePolicy: 2,
211
+ ...(sandbox.readDeny.length === 0 ? {} : { readDeny: [...sandbox.readDeny] }), ...(sandbox.machLookup.length === 0 ? {} : { machLookup: [...sandbox.machLookup] }) }, provenance: { resolvedFrom: build.source, repairFrom } };
203
212
  }
204
213
  const PHASE_NOUN = { plan: "planner", build: "builder", repair: "repair", review: "reviewer" };
205
214
  export function resolveRouteCandidates(store, repo, pins = {}) {
@@ -1,24 +1,4 @@
1
- /** Batch checks: like a merge queue, for projects that run the full check after every build.
2
- *
3
- * With batching on (a per-project setting, off by default), a Full build doesn't run its check
4
- * inline. It asks for a batch check on its exact commit, and waits up to the window (10 minutes)
5
- * for another result of the same project and the same approved command. The batch pass then
6
- * merges the waiting results onto the recorded project base in a temporary, detached checkout
7
- * and runs the approved check once:
8
- *
9
- * - it passes: every result gets its own sealed receipt, "checked together with" the others,
10
- * naming the exact batch commit it tested;
11
- * - it fails: the batch is split in halves and each half checked, down to single results, so the
12
- * one that breaks it is found and the others still pass on their own;
13
- * - the results conflict when merged: each is checked on its own.
14
- *
15
- * Nothing is merged into a real branch. The batch commit exists only in the temporary checkout,
16
- * which is always removed; each result still lands on its own.
17
- *
18
- * The queue is the follow-up check ledger (result-follow-ups.ts), with `batch` requests: the same
19
- * claim, receipts, abandoned-check reading, status projection and Merge gate. The policy is an
20
- * append-only ledger entry, like the check level. */
21
- import { fencedCheckoutCommands } from "./invoke.js";
1
+ import { checkoutRunner, ranAtLine, widestRan } from "./invoke.js";
22
2
  import { mkdtempSync, rmSync } from "node:fs";
23
3
  import { tmpdir } from "node:os";
24
4
  import { join } from "node:path";
@@ -67,7 +47,8 @@ export async function runBatchChecks(store, root, deps = {}) {
67
47
  finish("The approved check changed while it waited. Run checks to check it now.");
68
48
  continue;
69
49
  }
70
- const key = `${ref.repo}\u0000${one.digest}`;
50
+ // A cohort shares one check level: results whose own checks ran at different levels are checked apart (check-access.ts).
51
+ const key = `${ref.repo}\u0000${one.digest}\u0000${store.runCheckAccess(one.runId)}`;
71
52
  cohorts.set(key, [...(cohorts.get(key) ?? []), { request: one.request, runId: one.runId, task: ref.externalId, head: one.head, base: one.base ?? null, at: one.at }]);
72
53
  }
73
54
  for (const [key, members] of cohorts) {
@@ -121,6 +102,8 @@ async function checkCohort(store, root, repo, cohort, deps) {
121
102
  const attempts = new Map(members.map(one => [one.request, []]));
122
103
  const bound = (text) => { const hits = scanForSecrets(text); return boundStreamHeadTail(hits.length > 0 ? redactSecretLines(text, hits) : text, 12 * 1024); };
123
104
  const section = (label, shown, result) => `=== ${label} ===\n$ ${shown}\n(exit ${result.code}${result.notFound ? ", could not start" : ""}${result.timedOut ? ", timed out" : ""})\n\n--- stdout ---\n${bound(result.stdout)}\n\n--- stderr ---\n${bound(result.stderr)}`;
105
+ // How each command actually ran (check-access.ts), per result: only the commands it was part of count for its receipt.
106
+ const ranAtOf = new Map();
124
107
  const note = (set, text) => { for (const one of set)
125
108
  logs.get(one.request).push(text); };
126
109
  const attempt = (set, tested, outcome) => { for (const one of set)
@@ -158,11 +141,15 @@ async function checkCohort(store, root, repo, cohort, deps) {
158
141
  return { conflict: false, tested, outcome: { ran: false, failure: "Stopped before the check started." }, setupFailed: false };
159
142
  // The approvals are re-read before every spawn: a revoked or changed command never runs.
160
143
  const liveSetup = store.liveWorktreeSetup(repo);
144
+ // Worked out again right before each command (check-access.ts): a narrowing meanwhile applies to the next one. Each
145
+ // member is credited with exactly the commands of this set.
146
+ const setRan = (level) => { for (const one of set)
147
+ ranAtOf.set(one.request, [...(ranAtOf.get(one.request) ?? []), level]); };
161
148
  if ((liveSetup?.digest ?? null) !== (setup?.digest ?? null))
162
149
  return { conflict: false, tested, outcome: { ran: false, failure: "The project's approved setup changed before the check started." }, setupFailed: false };
163
150
  if (liveSetup !== null) {
164
151
  const shell = approvedCommandShell(liveSetup.command);
165
- const prepared = await runWithIsolatedDatabase(fencedCheckoutCommands(store, repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: liveSetup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
152
+ const prepared = await runWithIsolatedDatabase(checkoutRunner(store, repo, checkout, runner, set.map(one => one.runId), setRan), shell.file, shell.args, { cwd: checkout, timeoutMs: liveSetup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
166
153
  note(set, section(`${label} · approved project setup on ${tested.slice(0, 7)}`, liveSetup.command, prepared));
167
154
  if (prepared.code !== 0 || prepared.timedOut || prepared.notFound)
168
155
  return { conflict: false, tested, outcome: { ran: false, failure: "The project's setup failed, so the check didn't run." }, setupFailed: true };
@@ -171,7 +158,7 @@ async function checkCohort(store, root, repo, cohort, deps) {
171
158
  if (live === null || live.digest !== command.digest)
172
159
  return { conflict: false, tested, outcome: { ran: false, failure: "The approved check changed before it started." }, setupFailed: false };
173
160
  const shell = approvedCommandShell(live.command);
174
- const ran = await runWithIsolatedDatabase(fencedCheckoutCommands(store, repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
161
+ const ran = await runWithIsolatedDatabase(checkoutRunner(store, repo, checkout, runner, set.map(one => one.runId), setRan), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
175
162
  note(set, section(`${label} · full check on ${tested.slice(0, 7)}`, live.command, ran));
176
163
  return { conflict: false, tested, setupFailed: false,
177
164
  outcome: ran.timedOut ? { ran: false, failure: "The check ran out of time." } : ran.notFound ? { ran: false, failure: "The check couldn't start." } : { ran: true, exitCode: ran.code } };
@@ -183,11 +170,12 @@ async function checkCohort(store, root, repo, cohort, deps) {
183
170
  };
184
171
  const seal = (one, mode, together, tested, outcome) => {
185
172
  const at = now();
186
- const text = logs.get(one.request).length === 0 ? "No command started." : logs.get(one.request).join("\n\n");
173
+ const ranLine = ranAtLine(widestRan(ranAtOf.get(one.request) ?? []));
174
+ const text = logs.get(one.request).length === 0 ? "No command started." : [...logs.get(one.request), ...(ranLine === null ? [] : [ranLine])].join("\n\n");
187
175
  const logId = storeEvidence(store, root, one.runId, "structured-output", `batch-check-${one.request}.log`, Buffer.from(text, "utf8"), FOLLOW_UP_LOG, at, { captureStatus: "ok" });
188
176
  const logArtifact = store.getArtifact(logId);
189
177
  const batch = { mode, tested: tested ?? one.head, base, members: together.map(m => ({ task: m.task, run: m.runId, head: m.head })), attempts: attempts.get(one.request) };
190
- const receipt = JSON.stringify({ version: 1, request: one.request, run: one.runId, head: one.head, tested, level: "full",
178
+ const receipt = JSON.stringify({ version: 1, request: one.request, run: one.runId, head: one.head, tested, level: "full", checkAccess: widestRan(ranAtOf.get(one.request) ?? []),
191
179
  command: { repo: command.repo, command: command.command, digest: command.digest, approvedBy: command.approvedBy },
192
180
  result: outcome, batch, log: { artifactId: logArtifact.id, sha256: logArtifact.sha256, bytesStored: logArtifact.bytesStored, truncated: logArtifact.truncated } }, null, 1);
193
181
  storeEvidence(store, root, one.runId, "structured-output", `batch-check-${one.request}.json`, Buffer.from(receipt, "utf8"), FOLLOW_UP_RECEIPT, at, { captureStatus: "ok" });
@@ -489,6 +489,27 @@ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
489
489
  FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
490
490
  OTHER DEALINGS IN THE FONT SOFTWARE.
491
491
 
492
+ ## Anthropic sandbox-runtime — Apache-2.0
493
+
494
+ Copyright Anthropic PBC. https://github.com/anthropics/sandbox-runtime
495
+
496
+ `@anthropic-ai/sandbox-runtime` 0.0.79 is a dependency, installed whole from npm with its own `LICENSE` (and its
497
+ prebuilt Linux seccomp helpers, `vendor/seccomp/<arch>/apply-seccomp`). Toolroll calls it for every sandbox it runs
498
+ (`src/sandbox-runtime.ts`, `src/sandbox-launch.ts`) and copies or modifies none of its code.
499
+
500
+ Licensed under the Apache License, Version 2.0 (the "License");
501
+ you may not use this file except in compliance with the License.
502
+ You may obtain a copy of the License at
503
+
504
+ http://www.apache.org/licenses/LICENSE-2.0
505
+
506
+ Unless required by applicable law or agreed to in writing, software
507
+ distributed under the License is distributed on an "AS IS" BASIS,
508
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
509
+ See the License for the specific language governing permissions and
510
+ limitations under the License.
511
+
512
+
492
513
  ## Bundled dependency: @floating-ui/core 1.8.0
493
514
 
494
515
  MIT License
package/dist/builder.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type NetworkProfile } from "./egress.js";
2
+ import { type CheckAccess } from "./check-access.js";
1
3
  import type { WritesMode } from "./writes-mode.js";
2
4
  import type { LaunchNetwork } from "./invoke.js";
3
5
  import type { ProviderId } from "./contracts/provider.js";
@@ -187,6 +189,13 @@ export declare function redactSecretAssignments(text: string): string;
187
189
  /** A build's network (egress.ts): the one its approval sealed, or — sealed before network policies existed — the
188
190
  * narrowed legacy one (provider hosts and the repository's registries, nothing added since). */
189
191
  export declare function launchNetworkOf(store: Store, taskRef: number, profile: ExecutionProfile): LaunchNetwork;
192
+ /** A build's checks' level and network: its approval's level as this platform can give it (sign-ins sealed before
193
+ * run sandboxed while sign-ins are off, and the run is stamped with that, what actually ran), and under sign-ins the network its
194
+ * approval sealed less its agent's own provider hosts (never its checks'). */
195
+ export declare function checkLaunchOf(store: Store, runId: number, profile: ExecutionProfile, platform?: NodeJS.Platform): {
196
+ access: CheckAccess;
197
+ network: NetworkProfile | null;
198
+ };
190
199
  /** Where a build may write (writes-mode.ts): what its approval sealed, or — sealed before write allowlists — anywhere
191
200
  * its fence allows, as it was approved. */
192
201
  export declare function launchWritesOf(profile: ExecutionProfile): WritesMode;
package/dist/builder.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { legacyNetwork, repositoryEcosystems } from "./egress.js";
2
+ import { CHECK_ACCESS_DEFAULT, checkAccessHere, checkNetworkOf } from "./check-access.js";
2
3
  import { checkoutGit } from "./teardown-git.js";
3
4
  import { isAcpProvider } from "./acp-agents.js";
4
5
  import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
@@ -50,6 +51,7 @@ import { stopRequestedFor, stopWords, underStopWatch } from "./task-control.js";
50
51
  import { witnessedRunner } from "./process-custody.js";
51
52
  import { runWithIsolatedDatabase } from "./child-database.js";
52
53
  import { recordWorktreeProcess, saveWorkPatch } from "./worktree.js";
54
+ import { launchSandboxOf, protectedChanges, protectedLinks } from "./agent-fence.js";
53
55
  import { approvalOf, digestOf, idleClock, isAcpProfile, profileDigestOf, routeParityProblem, watchdogsOf } from "./scope.js";
54
56
  import { legOf, routeDigestOf, routeFromJson } from "./phase-routing.js";
55
57
  import { execFileSync } from "node:child_process";
@@ -268,7 +270,26 @@ function buildCheckoutGit(given, worktree, mainCheckout = null) {
268
270
  /** A run's setup and check commands, in its build's fence, writing where the approval this build runs under sealed
269
271
  * (never the scope as it may since have been rewritten; invoke.ts fencedCommands). */
270
272
  function commandRunner(store, runId, profile, inner) {
271
- return fencedCommands(store, runId, launchWritesOf(profile), inner);
273
+ // How far its checks reach (check-access.ts): what its approval sealed, stamped on the run (again: the dispatch stamped
274
+ // it first) so a later check of its result never runs wider; under sign-ins, behind its checks' own network.
275
+ const { access, network } = checkLaunchOf(store, runId, profile);
276
+ store.stampRunCheckAccess(runId, access, network);
277
+ return fencedCommands(store, runId, launchWritesOf(profile), inner, undefined, access, network ?? undefined, launchSandboxOf(profile));
278
+ }
279
+ /** A build's checks' level and network: its approval's level as this platform can give it (sign-ins sealed before
280
+ * run sandboxed while sign-ins are off, and the run is stamped with that, what actually ran), and under sign-ins the network its
281
+ * approval sealed less its agent's own provider hosts (never its checks'). */
282
+ export function checkLaunchOf(store, runId, profile, platform = process.platform) {
283
+ const access = checkAccessHere(profile.checkAccess ?? CHECK_ACCESS_DEFAULT, platform);
284
+ if (access !== "sign-ins")
285
+ return { access, network: null };
286
+ const taskRef = store.getRun(runId)?.taskRef;
287
+ const sealed = taskRef === undefined ? undefined : launchNetworkOf(store, taskRef, profile);
288
+ // No sealed network to narrow from: its checks run sandboxed.
289
+ if (sealed === undefined || !("policy" in sealed))
290
+ return { access: CHECK_ACCESS_DEFAULT, network: null };
291
+ const repo = store.refForId(taskRef)?.repo ?? null;
292
+ return { access, network: checkNetworkOf(sealed.policy, profile.provider, repo === null ? { mode: "allowlist", hosts: [], loopback: "proxy-only" } : store.projectNetwork(repo)) };
272
293
  }
273
294
  /** Where a build may write (writes-mode.ts): what its approval sealed, or — sealed before write allowlists — anywhere
274
295
  * its fence allows, as it was approved. */
@@ -422,6 +443,12 @@ export async function build(store, request) {
422
443
  // resume below can match on them honestly.
423
444
  const provenScopeDigest = scope?.approvedDigest ?? "";
424
445
  const provenProfileDigest = profileDigestOf(effective.profile);
446
+ // Its checks' level and network, stamped now with the proof: a batched result that runs no command here still
447
+ // carries what its approval allows its checks (check-access.ts).
448
+ {
449
+ const launch = checkLaunchOf(store, request.runId, effective.profile);
450
+ store.stampRunCheckAccess(request.runId, launch.access, launch.network);
451
+ }
425
452
  store.stampRun(request.runId, {
426
453
  scopeDigest: provenScopeDigest,
427
454
  profileDigest: provenProfileDigest,
@@ -1350,7 +1377,7 @@ export async function build(store, request) {
1350
1377
  ...watchdogsOf(effective.profile, effective.timeoutMs),
1351
1378
  omitEnv: AGENT_ENV_DENYLIST,
1352
1379
  network: launchNetworkOf(store, request.taskRef, effective.profile),
1353
- writes: launchWritesOf(effective.profile),
1380
+ writes: launchWritesOf(effective.profile), sandbox: launchSandboxOf(effective.profile),
1354
1381
  ...(agent === undefined ? {} : { runner: agent }),
1355
1382
  onSpawn: (pid) => { spawned = true; request.onProviderSpawn?.(pid); },
1356
1383
  onStreamEvent: (event) => liveLog.observe(event),
@@ -2694,7 +2721,7 @@ async function resumeUnhandedWork(captured, sessionId) {
2694
2721
  ...watchdogsOf(effective.profile, effective.timeoutMs),
2695
2722
  omitEnv: AGENT_ENV_DENYLIST,
2696
2723
  network: launchNetworkOf(store, request.taskRef, effective.profile),
2697
- writes: launchWritesOf(effective.profile),
2724
+ writes: launchWritesOf(effective.profile), sandbox: launchSandboxOf(effective.profile),
2698
2725
  ...(agent === undefined ? {} : { runner: agent }),
2699
2726
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
2700
2727
  clock,
@@ -2909,7 +2936,7 @@ async function ingestPark(args) {
2909
2936
  ? watchdogsOf(args.profile, args.profile.repairTimeoutSeconds * 1000, true)
2910
2937
  : { timeoutMs: args.profile !== undefined ? args.profile.repairTimeoutSeconds * 1000 : REPAIR_TIMEOUT_MS }),
2911
2938
  omitEnv: AGENT_ENV_DENYLIST,
2912
- ...(args.profile === undefined ? {} : { network: launchNetworkOf(store, request.taskRef, args.profile), writes: launchWritesOf(args.profile) }),
2939
+ ...(args.profile === undefined ? {} : { network: launchNetworkOf(store, request.taskRef, args.profile), writes: launchWritesOf(args.profile), sandbox: launchSandboxOf(args.profile) }),
2913
2940
  ...(agent === undefined ? {} : { runner: agent }),
2914
2941
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
2915
2942
  clock,
@@ -3480,6 +3507,25 @@ async function commit(git, worktree, branch, taskId, scope, summary, preparedCan
3480
3507
  return { ok: true, committed: false, branch, summary };
3481
3508
  }
3482
3509
  }
3510
+ // The protected names' backstop: a result that wrote a hook, Git config, editor or tool settings, a shell profile or
3511
+ // an `.envrc` is refused, however it got written (the sandbox runtime holds the names there when a launch starts; one
3512
+ // made after that, or by Codex, gets through it). The checkout is kept; nothing is committed.
3513
+ const staged = await git(GIT, ["--no-optional-locks", "diff", "--cached", "--name-only", "--no-renames", "-z"], { cwd: worktree });
3514
+ if (staged.code !== 0)
3515
+ return { ok: false, reason: "commit-failure", message: firstLine(staged.stderr) };
3516
+ // And any staged link at a protected name or a folder on the way to one (mode 120000, from the raw listing).
3517
+ const raw = await git(GIT, ["--no-optional-locks", "diff", "--cached", "--raw", "--no-renames", "-z"], { cwd: worktree });
3518
+ if (raw.code !== 0)
3519
+ return { ok: false, reason: "commit-failure", message: firstLine(raw.stderr) };
3520
+ const rawParts = raw.stdout.split("\0");
3521
+ const links = [];
3522
+ for (let at = 0; at + 1 < rawParts.length; at += 2)
3523
+ if (rawParts[at].split(" ")[1] === "120000")
3524
+ links.push(rawParts[at + 1]);
3525
+ const forbidden = [...new Set([...protectedChanges(staged.stdout.split("\0").filter(one => one !== "")), ...protectedLinks(links)])];
3526
+ if (forbidden.length > 0) {
3527
+ return { ok: false, reason: "commit-failure", message: `The result changes ${forbidden.slice(0, 3).join(", ")}${forbidden.length > 3 ? ` and ${forbidden.length - 3} more` : ""}, which builds may not write (they run code or change tools for whoever opens the project next). Nothing was committed; the work is kept in ${worktree}.` };
3528
+ }
3483
3529
  // Setup can create untracked files as well as edit tracked manifests.
3484
3530
  // Re-prove the whole staged tree after the normal path exclusions, before
3485
3531
  // committing, so neither can widen an exact prepared candidate.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Check access (v132, docs/plans/check-access.md): how far a project's setup and check commands reach beyond the
3
+ * plane's check fence. `sandboxed` (the default) is the fence as it is; `sign-ins` also reads the exact sign-in files
4
+ * of Claude, Codex and gh, under the project's network fence; `unsandboxed` runs them with no OS fence, trusted by the
5
+ * whole instance, for a project whose checks must create sandboxes themselves (Toolroll's own). Widening is an instance
6
+ * operator's act with a password typed now; any approver for the project may narrow it back.
7
+ */
8
+ import { type NetworkProfile, type ProjectNetwork } from "./egress.js";
9
+ export declare const CHECK_ACCESS_LEVELS: readonly ["sandboxed", "sign-ins", "unsandboxed"];
10
+ export type CheckAccess = typeof CHECK_ACCESS_LEVELS[number];
11
+ export declare const CHECK_ACCESS_DEFAULT: CheckAccess;
12
+ export declare const isCheckAccess: (value: unknown) => value is CheckAccess;
13
+ /** A stored or sealed value as a level: anything missing or unreadable is the narrowest, never a wider one. */
14
+ export declare const checkAccessOf: (value: unknown) => CheckAccess;
15
+ export declare const widerThan: (a: CheckAccess, b: CheckAccess) => boolean;
16
+ /** The narrowest of several levels (none given: the default). */
17
+ export declare const narrowestAccess: (levels: readonly CheckAccess[]) => CheckAccess;
18
+ /** The level as a person reads it, in a sentence's middle ("Checks: …"). */
19
+ export declare function checkAccessWords(level: CheckAccess): string;
20
+ /** What each wider level means, said before anyone chooses it. */
21
+ export declare const CHECK_ACCESS_WARNINGS: Readonly<Record<Exclude<CheckAccess, "sandboxed">, string>>;
22
+ /** The services whose sign-ins a `sign-ins` check reads: Claude's and Codex's own hosts (egress.ts), and GitHub's. */
23
+ export declare const SIGN_IN_HOSTS: readonly string[];
24
+ /** Which of those a project's network doesn't allow (an open network allows all). The check's network is never widened
25
+ * for them: the operator adds them, as a network change of its own. */
26
+ export declare function signInHostsMissing(network: ProjectNetwork): string[];
27
+ /** The narrower of two networks: every host both allow (a wildcard allows what it covers), local services only if both
28
+ * do, open only if both are. A check never reaches what either its sealed network or the project's current one refuses. */
29
+ export declare function narrowestNetwork(a: NetworkProfile, b: NetworkProfile): NetworkProfile;
30
+ /** A build's checks' own network: what its approval sealed, less its agent's provider hosts (those are the agent's, never
31
+ * its checks'); narrower if a project host happens to be one of them. */
32
+ export declare function checkNetworkOf(sealed: NetworkProfile, provider: string, project: ProjectNetwork): NetworkProfile;
33
+ /** On (0.9.70, sandbox parity C10): a `sign-ins` check reads its sign-in files itself, through its sandbox, by name
34
+ * (agent-fence.ts signInGrants). Nothing privileged reads or copies one, so the race that kept sign-ins off (a folder
35
+ * swapped between a privileged check and its read) has nothing to race; a link planted at a sign-in name leads, inside
36
+ * the sandbox's own view, to a denied path. Every platform with an OS fence. */
37
+ export declare const SIGN_INS_READY = true;
38
+ export declare const signInsAvailable: (_platform?: NodeJS.Platform) => boolean;
39
+ /** Why sign-ins can't be chosen here, said plainly (null: they can). */
40
+ export declare const signInsUnavailableWords: (platform?: NodeJS.Platform) => string | null;
41
+ /** The level checks actually get on this platform: `sign-ins` where it isn't available is `sandboxed` (narrower, never
42
+ * wider). Everything that seals, launches or stamps a check's level goes through this. */
43
+ export declare const checkAccessHere: (level: CheckAccess, platform?: NodeJS.Platform) => CheckAccess;
44
+ /** How a project's checks actually run here, as the settings page and `project check-access` say it: with no OS fence
45
+ * on this machine and unconfined running allowed by an operator, without a sandbox whatever is saved (never said to be
46
+ * sandboxed); otherwise the saved level as this platform can give it. */
47
+ export declare function checkAccessRunsAs(level: CheckAccess, here: {
48
+ platform: NodeJS.Platform;
49
+ fenced: boolean;
50
+ unconfinedAllowed: boolean;
51
+ }): CheckAccess;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Check access (v132, docs/plans/check-access.md): how far a project's setup and check commands reach beyond the
3
+ * plane's check fence. `sandboxed` (the default) is the fence as it is; `sign-ins` also reads the exact sign-in files
4
+ * of Claude, Codex and gh, under the project's network fence; `unsandboxed` runs them with no OS fence, trusted by the
5
+ * whole instance, for a project whose checks must create sandboxes themselves (Toolroll's own). Widening is an instance
6
+ * operator's act with a password typed now; any approver for the project may narrow it back.
7
+ */
8
+ import { PROVIDER_HOSTS } from "./egress.js";
9
+ export const CHECK_ACCESS_LEVELS = ["sandboxed", "sign-ins", "unsandboxed"];
10
+ export const CHECK_ACCESS_DEFAULT = "sandboxed";
11
+ export const isCheckAccess = (value) => typeof value === "string" && CHECK_ACCESS_LEVELS.includes(value);
12
+ /** A stored or sealed value as a level: anything missing or unreadable is the narrowest, never a wider one. */
13
+ export const checkAccessOf = (value) => isCheckAccess(value) ? value : CHECK_ACCESS_DEFAULT;
14
+ const RANK = { sandboxed: 0, "sign-ins": 1, unsandboxed: 2 };
15
+ export const widerThan = (a, b) => RANK[a] > RANK[b];
16
+ /** The narrowest of several levels (none given: the default). */
17
+ export const narrowestAccess = (levels) => levels.reduce((low, one) => RANK[one] < RANK[low] ? one : low, levels.length === 0 ? CHECK_ACCESS_DEFAULT : "unsandboxed");
18
+ /** The level as a person reads it, in a sentence's middle ("Checks: …"). */
19
+ export function checkAccessWords(level) {
20
+ return level === "sandboxed" ? "sandboxed" : level === "sign-ins" ? "sandboxed, with model and GitHub sign-ins" : "not sandboxed";
21
+ }
22
+ /** What each wider level means, said before anyone chooses it. */
23
+ export const CHECK_ACCESS_WARNINGS = {
24
+ "sign-ins": "This project's checks can read your Claude, Codex and GitHub sign-ins, even where the project hides your home folder's files from builds. Its tests could use or send those credentials anywhere its network setting allows. Only choose this for projects you trust.",
25
+ unsandboxed: "This project's checks get your full access to this machine and to Toolroll itself, including its database and every sign-in. Only for a project whose checks must create sandboxes themselves, such as Toolroll's own.",
26
+ };
27
+ /** The services whose sign-ins a `sign-ins` check reads: Claude's and Codex's own hosts (egress.ts), and GitHub's. */
28
+ export const SIGN_IN_HOSTS = [...PROVIDER_HOSTS["claude"], ...PROVIDER_HOSTS["codex"], "github.com", "api.github.com"];
29
+ /** Which of those a project's network doesn't allow (an open network allows all). The check's network is never widened
30
+ * for them: the operator adds them, as a network change of its own. */
31
+ export function signInHostsMissing(network) {
32
+ if (network.mode === "open")
33
+ return [];
34
+ const covered = (host) => network.hosts.some(one => one === host || (one.startsWith("*.") && host.endsWith(one.slice(1))));
35
+ return SIGN_IN_HOSTS.filter(host => !covered(host));
36
+ }
37
+ /** The narrower of two networks: every host both allow (a wildcard allows what it covers), local services only if both
38
+ * do, open only if both are. A check never reaches what either its sealed network or the project's current one refuses. */
39
+ export function narrowestNetwork(a, b) {
40
+ const loopback = a.loopback === "any" && b.loopback === "any" ? "any" : "proxy-only";
41
+ if (a.mode === "open" && b.mode === "open")
42
+ return { version: 1, mode: "open", hosts: [], loopback };
43
+ if (a.mode === "open")
44
+ return { ...b, loopback };
45
+ if (b.mode === "open")
46
+ return { ...a, loopback };
47
+ const covers = (list, host) => list.some(one => one === host || (one.startsWith("*.") && (host.endsWith(one.slice(1)) || host === one)));
48
+ const hosts = [...new Set([...a.hosts.filter(host => covers(b.hosts, host)), ...b.hosts.filter(host => covers(a.hosts, host))])].sort();
49
+ return { version: 1, mode: "allowlist", hosts, loopback };
50
+ }
51
+ /** A build's checks' own network: what its approval sealed, less its agent's provider hosts (those are the agent's, never
52
+ * its checks'); narrower if a project host happens to be one of them. */
53
+ export function checkNetworkOf(sealed, provider, project) {
54
+ if (sealed.mode === "open")
55
+ return sealed;
56
+ // A provider host the project itself lists (an operator added it, for its sign-ins, say) is the checks' too.
57
+ const agent = new Set((PROVIDER_HOSTS[provider] ?? []).filter(host => !project.hosts.includes(host)));
58
+ return { ...sealed, hosts: sealed.hosts.filter(host => !agent.has(host)) };
59
+ }
60
+ /** On (0.9.70, sandbox parity C10): a `sign-ins` check reads its sign-in files itself, through its sandbox, by name
61
+ * (agent-fence.ts signInGrants). Nothing privileged reads or copies one, so the race that kept sign-ins off (a folder
62
+ * swapped between a privileged check and its read) has nothing to race; a link planted at a sign-in name leads, inside
63
+ * the sandbox's own view, to a denied path. Every platform with an OS fence. */
64
+ export const SIGN_INS_READY = true;
65
+ export const signInsAvailable = (_platform = process.platform) => SIGN_INS_READY;
66
+ /** Why sign-ins can't be chosen here, said plainly (null: they can). */
67
+ export const signInsUnavailableWords = (platform = process.platform) => signInsAvailable(platform) ? null
68
+ : "Not available yet: sign-ins can't be shared with checks safely. Choose Sandboxed or Not sandboxed.";
69
+ /** The level checks actually get on this platform: `sign-ins` where it isn't available is `sandboxed` (narrower, never
70
+ * wider). Everything that seals, launches or stamps a check's level goes through this. */
71
+ export const checkAccessHere = (level, platform = process.platform) => level === "sign-ins" && !signInsAvailable(platform) ? CHECK_ACCESS_DEFAULT : level;
72
+ /** How a project's checks actually run here, as the settings page and `project check-access` say it: with no OS fence
73
+ * on this machine and unconfined running allowed by an operator, without a sandbox whatever is saved (never said to be
74
+ * sandboxed); otherwise the saved level as this platform can give it. */
75
+ export function checkAccessRunsAs(level, here) {
76
+ return !here.fenced && here.unconfinedAllowed ? "unsandboxed" : checkAccessHere(level, here.platform);
77
+ }
@@ -1,3 +1,5 @@
1
+ import type { CodingPermissions } from "./coding-workspace.js";
2
+ import type { Store } from "./store.js";
1
3
  import { type ProcessObservationFailure } from "./process-tree.js";
2
4
  /** Read-only recovery witnesses, never authority to signal historical PIDs.
3
5
  * macOS ancestry is observational; a missed/failed observation stays fenced.
@@ -70,3 +72,15 @@ export declare function createCodexCodingProvider(options?: {
70
72
  command?: string;
71
73
  args?: string[];
72
74
  }): CodingProvider;
75
+ /**
76
+ * An interactive Codex session's confinement (docs/plans/sandbox-parity.md C3): the permission profile a Codex build
77
+ * gets (agent-fence.ts codexFenceConfig), as the thread's config overrides: Toolroll's state, every other launch's
78
+ * folder and every other checkout in the plane's pools and the coding sessions' own pool denied, the session's own
79
+ * worktree given back; Codex's workspace writes; no network (Codex's command sandbox can't route through a proxy, and
80
+ * sessions have never had one). The worktree is bound to its project by the plane's own records first; anything that
81
+ * doesn't prove refuses the session.
82
+ */
83
+ export declare function codingSessionPermissions(store: Pick<Store, "databaseFile" | "listWorktrees" | "projectSandbox">, worktreeRoot: string): (input: {
84
+ repo: string;
85
+ worktree: string;
86
+ }) => CodingPermissions;
@@ -1,6 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { TextDecoder } from "node:util";
3
- import { hostname } from "node:os";
3
+ import { homedir, hostname } from "node:os";
4
+ import { readDenyPath } from "./project-sandbox.js";
5
+ import { dirname, join } from "node:path";
6
+ import { codexFenceConfig, fenceFor, FENCE_POLICY_CURRENT, runFolders, taskBoundary, trustedWorktreeBinding } from "./agent-fence.js";
7
+ import { poolRootOf } from "./worktree.js";
4
8
  import { currentBootId } from "./boot-identity.js";
5
9
  import { createContainer, currentContainment } from "./containment.js";
6
10
  import { processMayBeAlive } from "./process-liveness.js";
@@ -423,3 +427,31 @@ export function createCodexCodingProvider(options = {}) {
423
427
  close,
424
428
  };
425
429
  }
430
+ /**
431
+ * An interactive Codex session's confinement (docs/plans/sandbox-parity.md C3): the permission profile a Codex build
432
+ * gets (agent-fence.ts codexFenceConfig), as the thread's config overrides: Toolroll's state, every other launch's
433
+ * folder and every other checkout in the plane's pools and the coding sessions' own pool denied, the session's own
434
+ * worktree given back; Codex's workspace writes; no network (Codex's command sandbox can't route through a proxy, and
435
+ * sessions have never had one). The worktree is bound to its project by the plane's own records first; anything that
436
+ * doesn't prove refuses the session.
437
+ */
438
+ export function codingSessionPermissions(store, worktreeRoot) {
439
+ return input => sessionPermissions(store, { ...input, worktreeRoot });
440
+ }
441
+ function sessionPermissions(store, input) {
442
+ const databaseFile = store.databaseFile();
443
+ if (databaseFile === null || databaseFile === "" || databaseFile === ":memory:")
444
+ return { problem: "The coding session has no state folder to fence, so it was not started." };
445
+ try {
446
+ const poolRoots = [join(dirname(databaseFile), "worktrees"), input.worktreeRoot, ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
447
+ const binding = trustedWorktreeBinding(input.repo, input.worktree, poolRoots);
448
+ const boundary = taskBoundary({ worktree: input.worktree, provider: "codex", binding, policy: FENCE_POLICY_CURRENT });
449
+ // The project's read denies, read live (a session seals none; they only narrow).
450
+ const readDeny = store.projectSandbox(input.repo).readDeny.map(one => readDenyPath(one, homedir()));
451
+ const launch = fenceFor("build", { databaseFile, worktree: input.worktree, boundary, binding, runFolders: runFolders(null), poolRoots, readDeny, policy: FENCE_POLICY_CURRENT });
452
+ return { config: codexFenceConfig(launch.fence, false, launch.fenceGrants) };
453
+ }
454
+ catch {
455
+ return { problem: "The session's worktree couldn't be bound to its project, so the session was not started." };
456
+ }
457
+ }
@@ -24,9 +24,22 @@ export declare class CodingActionError extends Error {
24
24
  export declare class CodingStateConflictError extends CodingActionError {
25
25
  constructor(id: string);
26
26
  }
27
+ /** How a session's commands are confined (coding-provider.ts codingSessionPermissions): Toolroll's permission profile as
28
+ * the thread's config overrides, or a refusal. Required: no session starts without its caller saying which. */
29
+ export type CodingPermissions = {
30
+ config: Record<string, unknown>;
31
+ } | {
32
+ sandbox: 'workspace-write';
33
+ } | {
34
+ problem: string;
35
+ };
27
36
  type Options = {
28
37
  database: string;
29
38
  worktreeRoot: string;
39
+ permissions: (input: {
40
+ repo: string;
41
+ worktree: string;
42
+ }) => CodingPermissions;
30
43
  provider?: () => CodingProvider;
31
44
  admissionPaused?: () => boolean;
32
45
  authorize?: (actor: CodingActor, repo: string) => boolean;
@@ -92,6 +105,8 @@ export declare class CodingWorkspace {
92
105
  private putItem;
93
106
  private flush;
94
107
  private item;
108
+ /** The thread's confinement, made right before each start or resume from the plane's own records. */
109
+ private permissionsFor;
95
110
  private native;
96
111
  private request;
97
112
  private event;
@@ -278,6 +278,13 @@ export class CodingWorkspace {
278
278
  const clientId = string(item['clientId']) || string(item['clientUserMessageId']);
279
279
  return { id, type, text, status: string(item['status']) || null, ...(type === 'userMessage' && clientId ? { clientId } : {}) };
280
280
  }
281
+ /** The thread's confinement, made right before each start or resume from the plane's own records. */
282
+ permissionsFor(repo, worktree) {
283
+ const made = this.options.permissions({ repo, worktree });
284
+ if ('problem' in made)
285
+ throw Error(made.problem);
286
+ return made;
287
+ }
281
288
  native() {
282
289
  if (this.closed || this.closing || this.persistenceFailed)
283
290
  throw Error('The coding server cannot save activity. Reopen the workspace after the server recovers.');
@@ -445,7 +452,7 @@ export class CodingWorkspace {
445
452
  this.authorize(actor, repo);
446
453
  this.admit();
447
454
  nativeStartupAttempted = true;
448
- const result = object(await this.request('thread/start', { cwd: worktree, approvalPolicy: 'on-request', approvalsReviewer: 'user', sandbox: 'workspace-write', ...(input.model ? { model: input.model } : {}), developerInstructions: CODING_INSTRUCTIONS + (context?.text ?? ''), persistExtendedHistory: true }));
455
+ const result = object(await this.request('thread/start', { cwd: worktree, approvalPolicy: 'on-request', approvalsReviewer: 'user', ...this.permissionsFor(repo, worktree), ...(input.model ? { model: input.model } : {}), developerInstructions: CODING_INSTRUCTIONS + (context?.text ?? ''), persistExtendedHistory: true }));
449
456
  const thread = object(result['thread']), nativeThreadId = string(thread['id']);
450
457
  if (!nativeThreadId)
451
458
  throw Error('The agent did not return a session identity.');
@@ -494,7 +501,7 @@ export class CodingWorkspace {
494
501
  this.authorize({ name: session.owner, generation: session.generation }, session.repo);
495
502
  if (session.context)
496
503
  verifyCodingContext(session.context, session.repo, session.base);
497
- const result = object(await this.request('thread/resume', { threadId: session.nativeThreadId, cwd: session.worktree, approvalPolicy: 'on-request', approvalsReviewer: 'user', sandbox: 'workspace-write', ...(session.model ? { model: session.model } : {}), developerInstructions: CODING_INSTRUCTIONS + (session.context?.text ?? ''), persistExtendedHistory: true }));
504
+ const result = object(await this.request('thread/resume', { threadId: session.nativeThreadId, cwd: session.worktree, approvalPolicy: 'on-request', approvalsReviewer: 'user', ...this.permissionsFor(session.repo, session.worktree), ...(session.model ? { model: session.model } : {}), developerInstructions: CODING_INSTRUCTIONS + (session.context?.text ?? ''), persistExtendedHistory: true }));
498
505
  const thread = object(result['thread']);
499
506
  if (thread['id'] !== session.nativeThreadId)
500
507
  throw Error('The agent returned a different conversation. Continuity could not be verified.');
@@ -84,6 +84,8 @@ export declare const CONSOLE_FORMS: {
84
84
  readonly projectLiveView: FormContract<"act" | "nonce" | "password" | "repo">;
85
85
  readonly projectNetwork: FormContract<"hosts" | "local" | "mode" | "password" | "repo">;
86
86
  readonly projectWrites: FormContract<"password" | "repo" | "writes">;
87
+ readonly projectCheckAccess: FormContract<"confirm-open" | "level" | "password" | "repo">;
88
+ readonly projectSandbox: FormContract<"mach" | "password" | "read-deny" | "repo">;
87
89
  readonly projectDelete: FormContract<"name" | "password" | "repo" | "step">;
88
90
  readonly policy: FormContract<"ceiling" | "models" | "password" | "provider" | "tools">;
89
91
  readonly approval: FormContract<"not_requester" | "password" | "paths" | "protect" | "repo">;
@@ -93,6 +93,8 @@ export const CONSOLE_FORMS = {
93
93
  projectLiveView: formContract("POST /settings/project/live-view", ["repo", "act", "password", "nonce"]),
94
94
  projectNetwork: formContract("POST /settings/project/network", ["repo", "mode", "local", "hosts", "password"]),
95
95
  projectWrites: formContract("POST /settings/project/writes", ["repo", "writes", "password"]),
96
+ projectCheckAccess: formContract("POST /settings/project/check-access", ["repo", "level", "confirm-open", "password"]),
97
+ projectSandbox: formContract("POST /settings/project/sandbox", ["repo", "read-deny", "mach", "password"]),
96
98
  projectDelete: formContract("POST /settings/project/delete", ["repo", "name", "step", "password"]),
97
99
  policy: formContract("POST /settings/policy", ["password", "provider", "models", "tools", "ceiling"]),
98
100
  approval: formContract("POST /settings/approval", ["repo", "password", "protect", "paths", "not_requester"]),
package/dist/doctor.d.ts CHANGED
@@ -17,6 +17,13 @@ export type DoctorReport = {
17
17
  findings: DoctorFinding[];
18
18
  unreadable: DoctorUnreadable[];
19
19
  notes: string[];
20
+ /** The sandbox every build runs in (sandbox-runtime.ts): Anthropic's sandbox-runtime, its version, and why it can't
21
+ * sandbox here (null when it can). */
22
+ sandbox?: {
23
+ engine: string;
24
+ version: string;
25
+ problem: string | null;
26
+ };
20
27
  };
21
28
  export type DoctorDeps = {
22
29
  platform?: NodeJS.Platform;
package/dist/doctor.js CHANGED
@@ -24,6 +24,7 @@ import { envelopeJson } from "./envelope.js";
24
24
  import { run as runCommand } from "./exec.js";
25
25
  import { OWN_KEY_ENV } from "./keys.js";
26
26
  import { ALL_CREDENTIAL_ENV } from "./provider.js";
27
+ import { SANDBOX_RUNTIME, sandboxRuntimeProblem, sandboxRuntimeWords } from "./sandbox-runtime.js";
27
28
  /** Names that say they hold a credential. */
28
29
  const CREDENTIAL_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|SECRET|SECRET_?KEY|TOKEN|AUTH_?TOKEN|PASSWORD|PASSWD|CREDENTIALS?|PAT)(?:_|$)/i;
29
30
  /** Names that only point at one: a path, a socket, an address. */
@@ -401,6 +402,8 @@ export function doctorLines(report) {
401
402
  }
402
403
  for (const note of report.notes)
403
404
  lines.push("", `Note: ${note}`);
405
+ if (report.sandbox !== undefined)
406
+ lines.push("", `Sandbox: ${sandboxRuntimeWords()}${report.sandbox.problem === null ? ", working here." : `, not available here: ${report.sandbox.problem}. Builds don't start unless an instance operator allows unconfined agents.`}`);
404
407
  if (report.checked.length > 0)
405
408
  lines.push("", "Checked:", ...report.checked.map(one => ` ${one}`));
406
409
  return lines;
@@ -418,6 +421,7 @@ export async function runDoctorCommand(argv, write, deps = {}) {
418
421
  return 2;
419
422
  }
420
423
  const report = await auditServiceEnvironment(deps);
424
+ report.sandbox = { engine: SANDBOX_RUNTIME.name, version: SANDBOX_RUNTIME.version, problem: sandboxRuntimeProblem() };
421
425
  if (json)
422
426
  write(envelopeJson({ command: "doctor", ...report }));
423
427
  else