toolroll 0.9.69 → 0.9.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +180 -115
  5. package/dist/agent-fence.js +413 -535
  6. package/dist/agentconfig.js +6 -1
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  8. package/dist/builder.js +24 -4
  9. package/dist/check-access.d.ts +6 -9
  10. package/dist/check-access.js +7 -10
  11. package/dist/coding-provider.d.ts +14 -0
  12. package/dist/coding-provider.js +33 -1
  13. package/dist/coding-workspace.d.ts +15 -0
  14. package/dist/coding-workspace.js +9 -2
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/doctor.d.ts +7 -0
  18. package/dist/doctor.js +4 -0
  19. package/dist/egress.d.ts +2 -0
  20. package/dist/egress.js +13 -4
  21. package/dist/exec.d.ts +17 -3
  22. package/dist/exec.js +54 -17
  23. package/dist/flow-actions.d.ts +1 -0
  24. package/dist/flow-actions.js +3 -2
  25. package/dist/flow-code.d.ts +31 -2
  26. package/dist/flow-code.js +57 -5
  27. package/dist/flow-steps.d.ts +4 -0
  28. package/dist/flow-steps.js +9 -5
  29. package/dist/flow-triggers.d.ts +4 -0
  30. package/dist/flow-triggers.js +8 -3
  31. package/dist/invoke.d.ts +18 -8
  32. package/dist/invoke.js +98 -115
  33. package/dist/operate.js +46 -0
  34. package/dist/project-delete-ui.d.ts +7 -6
  35. package/dist/project-delete-ui.js +36 -2
  36. package/dist/project-sandbox.d.ts +59 -0
  37. package/dist/project-sandbox.js +83 -0
  38. package/dist/project-tools.d.ts +29 -5
  39. package/dist/project-tools.js +43 -7
  40. package/dist/release.d.ts +1 -1
  41. package/dist/release.js +1 -0
  42. package/dist/sandbox-launch.d.ts +1 -0
  43. package/dist/sandbox-launch.js +148 -0
  44. package/dist/sandbox-runtime.d.ts +62 -0
  45. package/dist/sandbox-runtime.js +140 -0
  46. package/dist/scope.d.ts +28 -0
  47. package/dist/scope.js +25 -5
  48. package/dist/scout-browser-lock.d.ts +69 -0
  49. package/dist/scout-browser-lock.js +69 -0
  50. package/dist/scout-browser.d.ts +60 -1
  51. package/dist/scout-browser.js +200 -4
  52. package/dist/scout.d.ts +9 -4
  53. package/dist/scout.js +14 -4
  54. package/dist/serve.js +3 -1
  55. package/dist/server/render-tasks.js +4 -1
  56. package/dist/server/route-table.js +1 -0
  57. package/dist/server/settings.js +27 -0
  58. package/dist/store.d.ts +31 -1
  59. package/dist/store.js +83 -4
  60. package/dist/subagent-tools.d.ts +1 -0
  61. package/dist/subagent-tools.js +7 -5
  62. package/dist/tool-broker.d.ts +2 -1
  63. package/dist/tool-broker.js +2 -2
  64. package/dist/tool-launcher.js +10 -16
  65. package/package.json +2 -1
  66. package/scripts/provider-canary.mjs +5 -6
  67. package/dist/egress-bridge.d.ts +0 -17
  68. package/dist/egress-bridge.js +0 -111
@@ -22,6 +22,7 @@
22
22
  import { PROJECT_NETWORK_DEFAULT, repositoryEcosystems, resolveNetwork } from "./egress.js";
23
23
  import { WRITES_DEFAULT } from "./writes-mode.js";
24
24
  import { checkAccessHere } from "./check-access.js";
25
+ import { PROJECT_SANDBOX_DEFAULT } from "./project-sandbox.js";
25
26
  import { isProviderId, validateSpec } from "./provider.js";
26
27
  import { ACP_LIMITS, CLAUDE_LIMITS, CODEX_SHAPED_LIMITS, GEMINI_LIMITS } from "./scope.js";
27
28
  import { ACP_AGENTS, isAcpProvider } from "./acp-agents.js";
@@ -203,7 +204,11 @@ export function resolveScopeProfile(store, repo, ref, flags, platform = process.
203
204
  // How far its checks reach (check-access.ts): the project's setting as checks can get it (sign-ins, off for now,
204
205
  // seal as sandboxed: an approval never claims what its checks won't get), sealed only when wider than sandboxed.
205
206
  const checkAccess = repo === null ? "sandboxed" : checkAccessHere(store.projectCheckAccess(repo), platform);
206
- return { ok: true, profile: { ...profile, network, writes, ...(checkAccess === "sandboxed" ? {} : { checkAccess }) }, provenance: { resolvedFrom: build.source, repairFrom } };
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 } };
207
212
  }
208
213
  const PHASE_NOUN = { plan: "planner", build: "builder", repair: "repair", review: "reviewer" };
209
214
  export function resolveRouteCandidates(store, repo, pins = {}) {
@@ -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.js CHANGED
@@ -51,6 +51,7 @@ import { stopRequestedFor, stopWords, underStopWatch } from "./task-control.js";
51
51
  import { witnessedRunner } from "./process-custody.js";
52
52
  import { runWithIsolatedDatabase } from "./child-database.js";
53
53
  import { recordWorktreeProcess, saveWorkPatch } from "./worktree.js";
54
+ import { launchSandboxOf, protectedChanges, protectedLinks } from "./agent-fence.js";
54
55
  import { approvalOf, digestOf, idleClock, isAcpProfile, profileDigestOf, routeParityProblem, watchdogsOf } from "./scope.js";
55
56
  import { legOf, routeDigestOf, routeFromJson } from "./phase-routing.js";
56
57
  import { execFileSync } from "node:child_process";
@@ -273,7 +274,7 @@ function commandRunner(store, runId, profile, inner) {
273
274
  // it first) so a later check of its result never runs wider; under sign-ins, behind its checks' own network.
274
275
  const { access, network } = checkLaunchOf(store, runId, profile);
275
276
  store.stampRunCheckAccess(runId, access, network);
276
- return fencedCommands(store, runId, launchWritesOf(profile), inner, undefined, access, network ?? undefined);
277
+ return fencedCommands(store, runId, launchWritesOf(profile), inner, undefined, access, network ?? undefined, launchSandboxOf(profile));
277
278
  }
278
279
  /** A build's checks' level and network: its approval's level as this platform can give it (sign-ins sealed before
279
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
@@ -1376,7 +1377,7 @@ export async function build(store, request) {
1376
1377
  ...watchdogsOf(effective.profile, effective.timeoutMs),
1377
1378
  omitEnv: AGENT_ENV_DENYLIST,
1378
1379
  network: launchNetworkOf(store, request.taskRef, effective.profile),
1379
- writes: launchWritesOf(effective.profile),
1380
+ writes: launchWritesOf(effective.profile), sandbox: launchSandboxOf(effective.profile),
1380
1381
  ...(agent === undefined ? {} : { runner: agent }),
1381
1382
  onSpawn: (pid) => { spawned = true; request.onProviderSpawn?.(pid); },
1382
1383
  onStreamEvent: (event) => liveLog.observe(event),
@@ -2720,7 +2721,7 @@ async function resumeUnhandedWork(captured, sessionId) {
2720
2721
  ...watchdogsOf(effective.profile, effective.timeoutMs),
2721
2722
  omitEnv: AGENT_ENV_DENYLIST,
2722
2723
  network: launchNetworkOf(store, request.taskRef, effective.profile),
2723
- writes: launchWritesOf(effective.profile),
2724
+ writes: launchWritesOf(effective.profile), sandbox: launchSandboxOf(effective.profile),
2724
2725
  ...(agent === undefined ? {} : { runner: agent }),
2725
2726
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
2726
2727
  clock,
@@ -2935,7 +2936,7 @@ async function ingestPark(args) {
2935
2936
  ? watchdogsOf(args.profile, args.profile.repairTimeoutSeconds * 1000, true)
2936
2937
  : { timeoutMs: args.profile !== undefined ? args.profile.repairTimeoutSeconds * 1000 : REPAIR_TIMEOUT_MS }),
2937
2938
  omitEnv: AGENT_ENV_DENYLIST,
2938
- ...(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) }),
2939
2940
  ...(agent === undefined ? {} : { runner: agent }),
2940
2941
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
2941
2942
  clock,
@@ -3506,6 +3507,25 @@ async function commit(git, worktree, branch, taskId, scope, summary, preparedCan
3506
3507
  return { ok: true, committed: false, branch, summary };
3507
3508
  }
3508
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
+ }
3509
3529
  // Setup can create untracked files as well as edit tracked manifests.
3510
3530
  // Re-prove the whole staged tree after the normal path exclusions, before
3511
3531
  // committing, so neither can widen an exact prepared candidate.
@@ -30,15 +30,12 @@ export declare function narrowestNetwork(a: NetworkProfile, b: NetworkProfile):
30
30
  /** A build's checks' own network: what its approval sealed, less its agent's provider hosts (those are the agent's, never
31
31
  * its checks'); narrower if a project host happens to be one of them. */
32
32
  export declare function checkNetworkOf(sealed: NetworkProfile, provider: string, project: ProjectNetwork): NetworkProfile;
33
- /** Off everywhere, for now (0.9.69): no `sign-ins`. Reading a sign-in file resolves its parent folders by path (Node
34
- * has no openat), and each review round found another way a build or a check could swap a folder on that path between
35
- * the checks and the read (on Linux a Claude build can write ~/.claude outright). Until that read is race-free, no
36
- * check gets sign-ins: setting the level is refused, a level saved or sealed before (or copied in from another
37
- * database) runs sandboxed, a scope seals sandboxed, and the sign-ins launch refuses first thing. The code behind it
38
- * (signInGrants, readGrantedFile, the copies, the network fence) stays, guarded, for when such a reader exists; flip
39
- * SIGN_INS_READY then (Linux stays off until its copies are race-free too). */
40
- export declare const SIGN_INS_READY = false;
41
- export declare const signInsAvailable: (platform?: NodeJS.Platform) => boolean;
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;
42
39
  /** Why sign-ins can't be chosen here, said plainly (null: they can). */
43
40
  export declare const signInsUnavailableWords: (platform?: NodeJS.Platform) => string | null;
44
41
  /** The level checks actually get on this platform: `sign-ins` where it isn't available is `sandboxed` (narrower, never
@@ -21,7 +21,7 @@ export function checkAccessWords(level) {
21
21
  }
22
22
  /** What each wider level means, said before anyone chooses it. */
23
23
  export const CHECK_ACCESS_WARNINGS = {
24
- "sign-ins": "This project's checks can read your Claude, Codex and GitHub sign-ins. Its tests could use or send those credentials anywhere its network setting allows. Only choose this for projects you trust.",
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
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
26
  };
27
27
  /** The services whose sign-ins a `sign-ins` check reads: Claude's and Codex's own hosts (egress.ts), and GitHub's. */
@@ -57,15 +57,12 @@ export function checkNetworkOf(sealed, provider, project) {
57
57
  const agent = new Set((PROVIDER_HOSTS[provider] ?? []).filter(host => !project.hosts.includes(host)));
58
58
  return { ...sealed, hosts: sealed.hosts.filter(host => !agent.has(host)) };
59
59
  }
60
- /** Off everywhere, for now (0.9.69): no `sign-ins`. Reading a sign-in file resolves its parent folders by path (Node
61
- * has no openat), and each review round found another way a build or a check could swap a folder on that path between
62
- * the checks and the read (on Linux a Claude build can write ~/.claude outright). Until that read is race-free, no
63
- * check gets sign-ins: setting the level is refused, a level saved or sealed before (or copied in from another
64
- * database) runs sandboxed, a scope seals sandboxed, and the sign-ins launch refuses first thing. The code behind it
65
- * (signInGrants, readGrantedFile, the copies, the network fence) stays, guarded, for when such a reader exists; flip
66
- * SIGN_INS_READY then (Linux stays off until its copies are race-free too). */
67
- export const SIGN_INS_READY = false;
68
- export const signInsAvailable = (platform = process.platform) => SIGN_INS_READY && platform !== "linux";
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;
69
66
  /** Why sign-ins can't be chosen here, said plainly (null: they can). */
70
67
  export const signInsUnavailableWords = (platform = process.platform) => signInsAvailable(platform) ? null
71
68
  : "Not available yet: sign-ins can't be shared with checks safely. Choose Sandboxed or Not sandboxed.";
@@ -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.');
@@ -85,6 +85,7 @@ export declare const CONSOLE_FORMS: {
85
85
  readonly projectNetwork: FormContract<"hosts" | "local" | "mode" | "password" | "repo">;
86
86
  readonly projectWrites: FormContract<"password" | "repo" | "writes">;
87
87
  readonly projectCheckAccess: FormContract<"confirm-open" | "level" | "password" | "repo">;
88
+ readonly projectSandbox: FormContract<"mach" | "password" | "read-deny" | "repo">;
88
89
  readonly projectDelete: FormContract<"name" | "password" | "repo" | "step">;
89
90
  readonly policy: FormContract<"ceiling" | "models" | "password" | "provider" | "tools">;
90
91
  readonly approval: FormContract<"not_requester" | "password" | "paths" | "protect" | "repo">;
@@ -94,6 +94,7 @@ export const CONSOLE_FORMS = {
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
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"]),
97
98
  projectDelete: formContract("POST /settings/project/delete", ["repo", "name", "step", "password"]),
98
99
  policy: formContract("POST /settings/policy", ["password", "provider", "models", "tools", "ceiling"]),
99
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
package/dist/egress.d.ts CHANGED
@@ -96,6 +96,8 @@ export declare function runNetworkFromValue(value: unknown): RunNetworkRecord |
96
96
  export type EgressProxy = {
97
97
  /** The proxy's address with this run's credentials, for HTTPS_PROXY and the like. */
98
98
  url: string;
99
+ /** This run's credential alone: a client presenting it (with any user name: the sandbox runtime names its own) is served. */
100
+ secret: string;
99
101
  port: number;
100
102
  counts: () => EgressCounts;
101
103
  close: () => Promise<void>;
package/dist/egress.js CHANGED
@@ -6,8 +6,8 @@
6
6
  * and the project's added hosts), and that snapshot is what the approval binds and the launch enforces. Nothing at
7
7
  * launch re-derives it from settings changed since or from files the agent wrote.
8
8
  *
9
- * The launch enforces it at the operating system (agent-fence.ts: Seatbelt's network rules on macOS, a network
10
- * namespace on Linux) and reaches the world through one proxy of its own (startEgressProxy), which admits only the
9
+ * The launch enforces it at the operating system (Anthropic's sandbox-runtime: its Seatbelt network rules on macOS, a
10
+ * network namespace on Linux with the proxy relayed in) and reaches the world through one proxy of its own (startEgressProxy), which admits only the
11
11
  * snapshot's hosts. The proxy never decrypts TLS: it restricts where a build connects, not what it sends there.
12
12
  */
13
13
  import { createServer, request as httpRequest } from "node:http";
@@ -272,7 +272,7 @@ export async function startEgressProxy(options) {
272
272
  const upstreamPort = options.upstreamPort ?? ((port) => port);
273
273
  const lookup = options.lookup ?? (async (host) => (await dnsLookup(host, { all: true, verbatim: true })).map(one => one.address));
274
274
  const secret = randomBytes(24).toString("base64url");
275
- const expected = Buffer.from(`Basic ${Buffer.from(`run:${secret}`).toString("base64")}`);
275
+ const expected = Buffer.from(secret);
276
276
  const sockets = new Set();
277
277
  const byHost = new Map();
278
278
  let connections = 0, refused = 0, otherHosts = 0;
@@ -300,8 +300,16 @@ export async function startEgressProxy(options) {
300
300
  }
301
301
  catch { /* a listener's trouble is not the build's */ }
302
302
  };
303
+ /** Basic credentials whose password is this run's secret, whatever the user name (the sandbox runtime puts its own
304
+ * attribution there). */
303
305
  const authorized = (incoming) => {
304
- const given = Buffer.from(String(incoming.headers["proxy-authorization"] ?? ""));
306
+ const header = String(incoming.headers["proxy-authorization"] ?? "");
307
+ const basic = /^Basic ([A-Za-z0-9+/=]+)$/.exec(header)?.[1];
308
+ if (basic === undefined)
309
+ return false;
310
+ const decoded = Buffer.from(basic, "base64").toString("utf8");
311
+ const colon = decoded.indexOf(":");
312
+ const given = Buffer.from(colon < 0 ? "" : decoded.slice(colon + 1));
305
313
  return given.length === expected.length && timingSafeEqual(given, expected);
306
314
  };
307
315
  /** The addresses a request may reach, or why it may not. */
@@ -412,6 +420,7 @@ export async function startEgressProxy(options) {
412
420
  const port = typeof address === "object" && address !== null ? address.port : 0;
413
421
  return {
414
422
  url: `http://run:${secret}@127.0.0.1:${port}`,
423
+ secret,
415
424
  port,
416
425
  counts: () => ({ connections, refused, hosts: [...byHost].map(([host, one]) => ({ host, ...one })).sort((a, b) => b.connections + b.refused - (a.connections + a.refused) || a.host.localeCompare(b.host)), otherHosts }),
417
426
  close: () => new Promise(done => {
package/dist/exec.d.ts CHANGED
@@ -183,18 +183,24 @@ export declare class ContainmentRefusal extends Error {
183
183
  readonly name = "ContainmentRefusal";
184
184
  }
185
185
  /**
186
- * The command as it runs inside the agent fence (sandbox-exec or bubblewrap wrapping it), or as it is when it has no
187
- * fence. A spawn whose confinement is required and that no backend here can wrap is refused (ContainmentRefusal):
188
- * nothing runs, and nothing falls back to running it unwrapped.
186
+ * The command as it runs inside the agent fence (Anthropic's sandbox-runtime wrapping it: agent-fence.ts
187
+ * sandboxedSpawn), with the environment that needs beside the caller's, or as it is when it has no fence. A spawn whose
188
+ * confinement is required and that no sandbox here can wrap is refused (ContainmentRefusal): nothing runs, and nothing
189
+ * falls back to running it unwrapped.
189
190
  */
190
191
  export declare function confined(file: string, args: readonly string[], bag: {
191
192
  fence?: readonly string[] | undefined;
192
193
  fenceGrants?: FenceGrants | undefined;
193
194
  confinement?: "required" | undefined;
195
+ cwd?: string | undefined;
196
+ temp?: string | undefined;
194
197
  }): {
195
198
  file: string;
196
199
  args: string[];
200
+ env: Record<string, string>;
197
201
  };
202
+ /** A spawn's environment with what its sandbox needs added (nothing added: the caller's, as it was). */
203
+ export declare function withSandboxEnv<T extends Record<string, string | undefined>>(env: T | undefined, added: Record<string, string>): T | Record<string, string | undefined> | undefined;
198
204
  /** Run IDs are local to a database. Connections to the same file share
199
205
  * custody; separate files and separate in-memory databases never do. */
200
206
  export declare function runOwnerTag(store: Store, runId: number): string;
@@ -298,6 +304,14 @@ export type AcpControl = {
298
304
  group: number;
299
305
  }[] | null;
300
306
  };
307
+ /**
308
+ * Whether a listed process is the agent's launch (`names`): by its listed name, or on Linux by the program it runs,
309
+ * since a listing there shows the main thread's name, which Node 24 sets to "MainThread".
310
+ */
311
+ export declare function agentNamed(row: {
312
+ pid: number;
313
+ name: string;
314
+ }, names: readonly string[], programOf?: (pid: number) => string | null): boolean;
301
315
  /** How long each shutdown step waits: session/close's answer, then exit after stdin closes, then exit after SIGTERM. */
302
316
  export declare const ACP_SHUTDOWN_MS: {
303
317
  readonly close: 2000;
package/dist/exec.js CHANGED
@@ -7,9 +7,9 @@
7
7
  * walks repos it has never seen, and one broken repo must not end the scan.
8
8
  */
9
9
  import { ACP_LINE_CAP, createAcpConversation } from "./acp-client.js";
10
- import { linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced } from "./agent-fence.js";
10
+ import { linuxFenceAvailable, macosFenceAvailable, sandboxedSpawn } from "./agent-fence.js";
11
11
  import { execFile, execFileSync, spawn } from "node:child_process";
12
- import { realpathSync } from "node:fs";
12
+ import { readlinkSync, realpathSync } from "node:fs";
13
13
  import { observeProcessTree, stopProcessTree } from "./process-tree.js";
14
14
  import { jsonlDiscriminants } from "./jsonl-discriminants.js";
15
15
  import { createContainer, currentContainment } from "./containment.js";
@@ -48,22 +48,35 @@ export class ContainmentRefusal extends Error {
48
48
  name = "ContainmentRefusal";
49
49
  }
50
50
  /**
51
- * The command as it runs inside the agent fence (sandbox-exec or bubblewrap wrapping it), or as it is when it has no
52
- * fence. A spawn whose confinement is required and that no backend here can wrap is refused (ContainmentRefusal):
53
- * nothing runs, and nothing falls back to running it unwrapped.
51
+ * The command as it runs inside the agent fence (Anthropic's sandbox-runtime wrapping it: agent-fence.ts
52
+ * sandboxedSpawn), with the environment that needs beside the caller's, or as it is when it has no fence. A spawn whose
53
+ * confinement is required and that no sandbox here can wrap is refused (ContainmentRefusal): nothing runs, and nothing
54
+ * falls back to running it unwrapped.
54
55
  */
55
56
  export function confined(file, args, bag) {
56
57
  const fence = bag.fence ?? [];
57
- if (fence.length > 0 && macosFenceAvailable())
58
- return macosFenced(file, args, fence, bag.fenceGrants);
59
- if (fence.length > 0 && linuxFenceAvailable())
60
- return linuxFenced(file, args, fence, bag.fenceGrants);
58
+ // A fence is only ever made by fenceFor (agent-fence.ts), which marks it required: one without the mark was put
59
+ // together some other way, and would run bare wherever no OS fence applies. Nothing runs.
60
+ if (fence.length > 0 && bag.confinement !== "required")
61
+ throw new ContainmentRefusal("This process was given a fence that isn't marked required, so it was not started.");
62
+ if (fence.length > 0 && (macosFenceAvailable() || linuxFenceAvailable())) {
63
+ try {
64
+ return sandboxedSpawn(file, args, fence, bag.fenceGrants, bag.cwd, bag.temp);
65
+ }
66
+ catch (error) {
67
+ throw new ContainmentRefusal(`The sandbox couldn't be made, so nothing was started: ${error instanceof Error ? error.message : String(error)}`);
68
+ }
69
+ }
61
70
  if (bag.confinement === "required") {
62
71
  throw new ContainmentRefusal(fence.length === 0
63
72
  ? "This process must run inside the agent fence, and it was given none."
64
73
  : "This machine can't confine a build (no macOS sandbox, no working bubblewrap), so nothing was started.");
65
74
  }
66
- return { file, args: [...args] };
75
+ return { file, args: [...args], env: {} };
76
+ }
77
+ /** A spawn's environment with what its sandbox needs added (nothing added: the caller's, as it was). */
78
+ export function withSandboxEnv(env, added) {
79
+ return Object.keys(added).length === 0 ? env : { ...(env ?? process.env), ...added };
67
80
  }
68
81
  /** A spawn callback refused custody AFTER the child existed: the caller
69
82
  * must reap exactly this child before answering. */
@@ -92,7 +105,9 @@ function rejectedChild(error, child) {
92
105
  */
93
106
  function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
94
107
  // The agent fence wraps the target itself (sandbox-exec then execs it, same pid).
95
- ({ file, args } = confined(file, args, bag));
108
+ const sandboxed = confined(file, args, { ...bag, cwd: spawnOptions.cwd, temp: spawnOptions.env?.["TMPDIR"] });
109
+ ({ file, args } = sandboxed);
110
+ spawnOptions = { ...spawnOptions, env: withSandboxEnv(spawnOptions.env, sandboxed.env) };
96
111
  // The policy refusal comes first: nothing is reserved, recorded or
97
112
  // spawned for a spawn that cannot be contained as required.
98
113
  const effective = contain ? currentContainment() : null;
@@ -553,7 +568,7 @@ export function run(file, args, options = {}) {
553
568
  // whose confinement is required and can't be wrapped refuses before anything runs.
554
569
  let wrapped;
555
570
  try {
556
- wrapped = confined(file, args, options);
571
+ wrapped = confined(file, args, { ...options, cwd, temp: childEnv?.["TMPDIR"] });
557
572
  }
558
573
  catch (error) {
559
574
  if (!(error instanceof ContainmentRefusal))
@@ -568,7 +583,7 @@ export function run(file, args, options = {}) {
568
583
  encoding: "utf8",
569
584
  shell: false,
570
585
  windowsHide: true,
571
- ...(childEnv === undefined ? {} : { env: childEnv }),
586
+ ...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env)),
572
587
  }, (error, stdout, stderr) => {
573
588
  if (error === null) {
574
589
  resolve({ result: { code: 0, stdout, stderr, timedOut: false, notFound: false }, transient: false });
@@ -1373,7 +1388,7 @@ function processRows() {
1373
1388
  * wrapper around it), down to the agent's own process (the first whose command is the agent's launch, `names`), then
1374
1389
  * every live, non-zombie process beneath it, with its group. The agent itself and any wrapper are never counted.
1375
1390
  */
1376
- function liveUnderAgent(root, names) {
1391
+ function liveUnderAgent(root, names, rootIsWrapper = false) {
1377
1392
  const rows = processRows();
1378
1393
  if (rows === null)
1379
1394
  return null;
@@ -1381,9 +1396,10 @@ function liveUnderAgent(root, names) {
1381
1396
  for (const row of rows)
1382
1397
  children.set(row.parent, [...(children.get(row.parent) ?? []), row]);
1383
1398
  const self = rows.find(row => row.pid === root);
1384
- const named = (row) => names.some(name => row.name === name || row.name.endsWith(`/${name}`));
1399
+ const named = (row) => agentNamed(row, names);
1385
1400
  // The agent: the root when it is the agent (macOS sandbox-exec execs in place), else the first named descendant.
1386
- let agent = self !== undefined && named(self) ? root : null;
1401
+ // On Linux the root is the sandbox launch helper (sandbox-launch.ts, itself node): never taken for the agent.
1402
+ let agent = !rootIsWrapper && self !== undefined && named(self) ? root : null;
1387
1403
  const pending = [...(children.get(root) ?? [])];
1388
1404
  while (agent === null && pending.length > 0) {
1389
1405
  const row = pending.shift();
@@ -1404,6 +1420,27 @@ function liveUnderAgent(root, names) {
1404
1420
  }
1405
1421
  return found;
1406
1422
  }
1423
+ /** The program a Linux process runs (its /proc exe), or null: elsewhere, or unreadable. */
1424
+ function linuxProgramOf(pid) {
1425
+ if (process.platform !== "linux")
1426
+ return null;
1427
+ try {
1428
+ return readlinkSync(`/proc/${pid}/exe`).replace(/ \(deleted\)$/, "").split("/").pop() || null;
1429
+ }
1430
+ catch {
1431
+ return null;
1432
+ }
1433
+ }
1434
+ /**
1435
+ * Whether a listed process is the agent's launch (`names`): by its listed name, or on Linux by the program it runs,
1436
+ * since a listing there shows the main thread's name, which Node 24 sets to "MainThread".
1437
+ */
1438
+ export function agentNamed(row, names, programOf = linuxProgramOf) {
1439
+ if (names.some(name => row.name === name || row.name.endsWith(`/${name}`)))
1440
+ return true;
1441
+ const program = programOf(row.pid);
1442
+ return program !== null && names.includes(program);
1443
+ }
1407
1444
  /** How long each shutdown step waits: session/close's answer, then exit after stdin closes, then exit after SIGTERM. */
1408
1445
  export const ACP_SHUTDOWN_MS = { close: 2_000, eof: 5_000, term: 5_000 };
1409
1446
  /** How long each handshake step (initialize, session/new, choosing the model) may take before the agent is stopped. */
@@ -1612,7 +1649,7 @@ export function runAcpSession(file, args, options = {}) {
1612
1649
  forceKill();
1613
1650
  });
1614
1651
  },
1615
- liveDescendants: () => process.platform === "win32" ? null : exited() || child.pid === undefined ? [] : liveUnderAgent(child.pid, agentNames),
1652
+ liveDescendants: () => process.platform === "win32" ? null : exited() || child.pid === undefined ? [] : liveUnderAgent(child.pid, agentNames, child.spawnargs.some(arg => arg.endsWith("sandbox-launch.js"))),
1616
1653
  });
1617
1654
  }
1618
1655
  catch { /* the caller's own failure */ }