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
@@ -24,11 +24,15 @@ import { createHash } from "node:crypto";
24
24
  import { spawn } from "node:child_process";
25
25
  import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
26
26
  import { homedir, tmpdir } from "node:os";
27
+ import { realpathSync } from "node:fs";
27
28
  import { dirname, isAbsolute, join } from "node:path";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import { scanForSecrets } from "./evidence.js";
30
31
  import { namedPath } from "./names.js";
31
32
  import { startToolBroker } from "./tool-broker.js";
33
+ import { buildCacheFolder, fenceFor, linuxFenceAvailable, macosFenceAvailable, runFolders } from "./agent-fence.js";
34
+ import { confined, ContainmentRefusal } from "./exec.js";
35
+ import { poolRootOf } from "./worktree.js";
32
36
  import { isAcpProvider } from "./acp-agents.js";
33
37
  import { SecretStoreError, destroyOwnFile, prepareSecretFolder, registerSecretFolder, pathIn, readOwnFile, readSealed, removeValue, secretBackend, secretFolder, secretStorage, serviceOf, withSecretLock, withSecretLockAsync, writeOwnFile, writeSealed } from "./tool-secret-store.js";
34
38
  import { ACCESS, GRANTED, REFRESH, mintRunToken, mintsRunTokens, oneAtATime, runScope, scopeList, signedIn } from "./oauth-run-token.js";
@@ -507,7 +511,7 @@ function toolNames(result) {
507
511
  return Array.isArray(tools) ? tools.map(one => one.name).filter((name) => typeof name === "string").slice(0, 200) : [];
508
512
  }
509
513
  /** Start the server once, ask for its tools, and stop it: the proof a build can use it. */
510
- export async function testTool(spec, values, options = {}) {
514
+ export async function testTool(spec, values, options) {
511
515
  const answer = await exchange(spec, values, { method: "tools/list", params: {}, doing: "listing its tools" }, options);
512
516
  return answer.ok ? { ok: true, tools: toolNames(answer.result) } : answer;
513
517
  }
@@ -516,7 +520,7 @@ export async function testTool(spec, values, options = {}) {
516
520
  * its description, what input it takes, and whether it says it only reads.
517
521
  * A subagent is offered these, each under its own rule.
518
522
  */
519
- export async function listProjectToolActions(spec, values, options = {}) {
523
+ export async function listProjectToolActions(spec, values, options) {
520
524
  const answer = await exchange(spec, values, { method: "tools/list", params: {}, doing: "listing its tools" }, options);
521
525
  return answer.ok ? { ok: true, actions: toolActions(answer.result) } : answer;
522
526
  }
@@ -537,7 +541,7 @@ export function toolActions(result) {
537
541
  * "Use a tool" step. The answer's text parts are kept; a tool that says it
538
542
  * failed (isError) is an answer, not trouble reaching it.
539
543
  */
540
- export async function callProjectTool(spec, values, name, args, options = {}) {
544
+ export async function callProjectTool(spec, values, name, args, options) {
541
545
  const answer = await exchange(spec, values, { method: "tools/call", params: { name, arguments: args }, doing: `using ${name}` }, options);
542
546
  if (!answer.ok)
543
547
  return answer;
@@ -552,9 +556,14 @@ function exchange(spec, values, request, options) {
552
556
  if (missing.length > 0)
553
557
  return Promise.resolve({ ok: false, problem: `Set ${missing.join(", ")} first.` });
554
558
  const timeoutMs = options.timeoutMs ?? 60_000;
555
- return spec.transport === "http" ? exchangeHttp(spec, values, timeoutMs, request) : exchangeStdio(spec, values, timeoutMs, options.env ?? process.env, options.omitEnv ?? [], request);
559
+ if (spec.transport === "http")
560
+ return exchangeHttp(spec, values, timeoutMs, request);
561
+ // A local server starts only in the server fence its caller made (toolServerFor), never bare.
562
+ if ("problem" in options.server)
563
+ return Promise.resolve({ ok: false, problem: options.server.problem });
564
+ return exchangeStdio(spec, values, timeoutMs, options.env ?? process.env, options.omitEnv ?? [], request, options.server.fence);
556
565
  }
557
- function exchangeStdio(spec, values, timeoutMs, base, omit, request) {
566
+ function exchangeStdio(spec, values, timeoutMs, base, omit, request, fence) {
558
567
  return new Promise(resolve => {
559
568
  const env = { ...base };
560
569
  for (const name of omit)
@@ -562,9 +571,14 @@ function exchangeStdio(spec, values, timeoutMs, base, omit, request) {
562
571
  Object.assign(env, values);
563
572
  let child;
564
573
  try {
565
- child = spawn(spec.command, spec.args, { env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
574
+ const launch = confined(spec.command, spec.args, { ...(fence ?? {}), temp: env["TMPDIR"] });
575
+ child = spawn(launch.file, launch.args, { env: { ...env, ...launch.env }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
566
576
  }
567
577
  catch (error) {
578
+ if (error instanceof ContainmentRefusal) {
579
+ resolve({ ok: false, problem: error.message });
580
+ return;
581
+ }
568
582
  resolve({ ok: false, problem: `It could not start: ${error instanceof Error ? error.message : "unknown error"}.` });
569
583
  return;
570
584
  }
@@ -1004,7 +1018,8 @@ export async function testToolOf(store, repo, name, now, options = {}) {
1004
1018
  const tool = projectToolsOf(store, repo).find(one => one.name === name);
1005
1019
  if (tool === undefined)
1006
1020
  return null;
1007
- const outcome = await testTool(tool.spec, readToolSecrets(repo, name, options.home), { ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) });
1021
+ const server = toolServerFor(store, repo, { ...(options.home === undefined ? {} : { home: options.home }), ...(options.available === undefined ? {} : { available: options.available }) });
1022
+ const outcome = await testTool(tool.spec, readToolSecrets(repo, name, options.home), { server, ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) });
1008
1023
  const test = outcome.ok ? { at: now.toISOString(), ok: true, tools: outcome.tools, problem: null } : { at: now.toISOString(), ok: false, tools: [], problem: outcome.problem };
1009
1024
  store.recordProjectToolTest(repo, name, JSON.stringify(test));
1010
1025
  return test;
@@ -1093,3 +1108,24 @@ export function splitCommandLine(line) {
1093
1108
  parts.push(current);
1094
1109
  return parts;
1095
1110
  }
1111
+ /**
1112
+ * The server fence for a project tool started outside a build (docs/plans/sandbox-parity.md C2): discovery, its Test
1113
+ * button, a flow's "Use a tool" step, a subagent's call. As the broker's servers are fenced: Toolroll's state, every
1114
+ * other launch's folder and the checkout pools denied, and nothing a build writes (its project's caches) read, so no
1115
+ * byte a build wrote can load into a process that holds the tool's secrets. Required: where this machine can't confine
1116
+ * it, it doesn't start unless an instance operator allowed unconfined agents.
1117
+ */
1118
+ export function toolServerFor(store, repo, options = {}) {
1119
+ const databaseFile = store.databaseFile();
1120
+ if (databaseFile === null || databaseFile === "" || databaseFile === ":memory:")
1121
+ return { fence: null };
1122
+ const available = options.available ?? (() => macosFenceAvailable() || linuxFenceAvailable());
1123
+ if (!available())
1124
+ return store.unfencedAgentsAllowed() ? { fence: null }
1125
+ : { problem: "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." };
1126
+ const home = options.home ?? homedir();
1127
+ const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
1128
+ const cache = buildCacheFolder(repo, home);
1129
+ const untrusted = existsSync(cache) ? [realpathSync(cache)] : [];
1130
+ return { fence: fenceFor("tool-server", { databaseFile, worktree: null, home, boundary: null, binding: null, runFolders: runFolders(null), poolRoots, extra: untrusted }) };
1131
+ }
package/dist/release.d.ts CHANGED
@@ -30,7 +30,7 @@
30
30
  */
31
31
  /** The pull-request checks a release waits for, by exact job name (.github/workflows/ci.yml). Every one must have
32
32
  * concluded `success` on the gated commit; src/ci-workflow.test.ts holds the workflow to this list. */
33
- export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "Windows Job Object containment"];
33
+ export declare const REQUIRED_PR_CHECKS: readonly ["node 22 · ubuntu-latest", "node 24 · ubuntu-latest", "linux native containment · delegated cgroup v2", "linux fence · sandbox-runtime", "Windows Job Object containment"];
34
34
  /** The checks a release tag runs (.github/workflows/publish.yml), awaited before Homebrew moves. */
35
35
  export declare const RELEASE_TAG_CHECKS: readonly ["node 22 · macos-latest", "node 24 · macos-latest", "publish"];
36
36
  export declare const RELEASE_STEPS: readonly ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
package/dist/release.js CHANGED
@@ -37,6 +37,7 @@ export const REQUIRED_PR_CHECKS = [
37
37
  "node 22 · ubuntu-latest",
38
38
  "node 24 · ubuntu-latest",
39
39
  "linux native containment · delegated cgroup v2",
40
+ "linux fence · sandbox-runtime",
40
41
  "Windows Job Object containment",
41
42
  ];
42
43
  /** The checks a release tag runs (.github/workflows/publish.yml), awaited before Homebrew moves. */
@@ -1,15 +1,4 @@
1
- /** Follow-ups on any result: "Run checks" and "Add tests".
2
- *
3
- * Run checks runs the project's quick or full check on the result's exact
4
- * commit, in a fresh checkout, after the build has finished. A person (or a
5
- * pull request opening) asks; the ask is a ledger entry; a worker runs it
6
- * once and seals its log and a receipt beside the result. A pass upgrades
7
- * what the result's Checks row says; a failure stays visible. Nothing here
8
- * rewrites the build's own check or reruns crew work.
9
- *
10
- * Add tests files a small task, unapproved like any other filing, to write
11
- * tests for that change. */
12
- import { fencedCheckoutCommands } from "./invoke.js";
1
+ import { checkoutRunner, ranAtLine, widestRan } from "./invoke.js";
13
2
  import { mkdtempSync, rmSync } from "node:fs";
14
3
  import { tmpdir } from "node:os";
15
4
  import { join } from "node:path";
@@ -188,6 +177,8 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
188
177
  const folder = mkdtempSync(join(deps.scratch ?? tmpdir(), "toolroll-checks-"));
189
178
  const checkout = join(folder, "checkout");
190
179
  const log = [];
180
+ // How each command actually ran (check-access.ts): the widest is on the receipt and in the log.
181
+ const ranAt = [];
191
182
  const bound = (text) => { const hits = scanForSecrets(text); return boundStreamHeadTail(hits.length > 0 ? redactSecretLines(text, hits) : text, 24 * 1024); };
192
183
  const note = (label, command, result) => log.push(`=== ${label} ===\n$ ${command}\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)}`);
193
184
  let result;
@@ -199,10 +190,11 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
199
190
  }
200
191
  else {
201
192
  const setup = store.liveWorktreeSetup(target.repo);
193
+ // Worked out again right before each command (check-access.ts): a narrowing meanwhile applies to the next one.
202
194
  let ready = true;
203
195
  if (setup !== null) {
204
196
  const shell = approvedCommandShell(setup.command);
205
- const prepared = await runWithIsolatedDatabase(fencedCheckoutCommands(store, target.repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
197
+ const prepared = await runWithIsolatedDatabase(checkoutRunner(store, target.repo, checkout, runner, [runId], level => ranAt.push(level)), shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
206
198
  note("Approved project setup", setup.command, prepared);
207
199
  if (prepared.code !== 0 || prepared.timedOut || prepared.notFound) {
208
200
  ready = false;
@@ -217,7 +209,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
217
209
  }
218
210
  else {
219
211
  const shell = approvedCommandShell(live.command);
220
- const ran = await runWithIsolatedDatabase(fencedCheckoutCommands(store, target.repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
212
+ const ran = await runWithIsolatedDatabase(checkoutRunner(store, target.repo, checkout, runner, [runId], level => ranAt.push(level)), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
221
213
  note(`${CHECK_LEVEL_WORDS[target.level]} check`, live.command, ran);
222
214
  result = 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 };
223
215
  }
@@ -231,10 +223,13 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
231
223
  rmSync(folder, { recursive: true, force: true });
232
224
  }
233
225
  const at = now();
226
+ const ranLine = ranAtLine(widestRan(ranAt));
227
+ if (ranLine !== null)
228
+ log.push(ranLine);
234
229
  const text = log.length === 0 ? "No command started." : log.join("\n\n");
235
230
  const logId = storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.log`, Buffer.from(text, "utf8"), FOLLOW_UP_LOG, at, { captureStatus: "ok" });
236
231
  const logArtifact = store.getArtifact(logId);
237
- const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, tested: target.head, level: target.level,
232
+ const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, tested: target.head, level: target.level, checkAccess: widestRan(ranAt),
238
233
  command: { repo: target.command.repo, command: target.command.command, digest: target.command.digest, approvedBy: target.command.approvedBy },
239
234
  result, log: { artifactId: logArtifact.id, sha256: logArtifact.sha256, bytesStored: logArtifact.bytesStored, truncated: logArtifact.truncated } }, null, 1);
240
235
  storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.json`, Buffer.from(receipt, "utf8"), FOLLOW_UP_RECEIPT, at, { captureStatus: "ok" });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The Linux launch helper (sandbox-runtime.ts): `node sandbox-launch.js`, handed one launch in its environment
3
+ * (TOOLROLL_SANDBOX_LAUNCH). It changes into the launch's folder (Anthropic's sandbox-runtime anchors its mandatory
4
+ * write denies there), starts the runtime's network relays to this run's proxy when the network is confined, asks the
5
+ * runtime for the bubblewrap + seccomp command, runs it (setting it up again, from a fresh look, if the sandbox failed
6
+ * before the launch itself started), passes on the signals it gets, and when the command ends removes the mount points
7
+ * the runtime made and stops the relays. It exits with the command's status. Nothing runs if
8
+ * the runtime refuses: the helper exits 126 with the reason.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { checkLinuxDependencies, cleanupBwrapMountPoints, getLinuxDependencyStatus, initializeLinuxNetworkBridge, wrapCommandWithSandboxLinux } from "@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js";
12
+ import { SANDBOX_LAUNCH_SPEC, shellQuote, withRuntimeTemp } from "./sandbox-runtime.js";
13
+ const REFUSED = 126;
14
+ /** The status a launch stopped before it ran ends with (128 + SIGTERM). */
15
+ const STOPPED = 143;
16
+ /** Why the runtime can't sandbox here now (bubblewrap, socat or its seccomp helper gone), or null: asked again before
17
+ * each attempt, never remembered from an earlier look. */
18
+ function missing() {
19
+ const checked = checkLinuxDependencies();
20
+ if (checked.errors.length > 0)
21
+ return checked.errors.join("; ");
22
+ return getLinuxDependencyStatus().hasSeccompApply ? null : "the sandbox runtime's seccomp helper isn't available";
23
+ }
24
+ async function main() {
25
+ const raw = process.env[SANDBOX_LAUNCH_SPEC];
26
+ delete process.env[SANDBOX_LAUNCH_SPEC];
27
+ if (raw === undefined) {
28
+ process.stderr.write("toolroll sandbox: no launch was given\n");
29
+ return REFUSED;
30
+ }
31
+ const spec = JSON.parse(raw);
32
+ process.chdir(spec.cwd);
33
+ // A stop at any point: what is running is passed it, and nothing starts after it.
34
+ let stopping = false;
35
+ let child = null;
36
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"])
37
+ process.on(signal, () => { stopping = true; try {
38
+ child?.kill(signal);
39
+ }
40
+ catch { /* gone */ } });
41
+ const network = spec.terms.network;
42
+ let bridge = null;
43
+ // The relays share the launch's process group: they are waited for (SIGKILL after a moment), so when the helper exits
44
+ // nothing of the launch is left in it to read as the agent's leftover.
45
+ const stopBridge = async () => {
46
+ await Promise.all([bridge?.httpBridgeProcess, bridge?.socksBridgeProcess].map(one => new Promise(done => {
47
+ if (one === undefined || one.exitCode !== null || one.signalCode !== null) {
48
+ done();
49
+ return;
50
+ }
51
+ const force = setTimeout(() => { try {
52
+ one.kill("SIGKILL");
53
+ }
54
+ catch { /* gone */ } }, 1_000);
55
+ one.once("exit", () => { clearTimeout(force); done(); });
56
+ try {
57
+ one.kill("SIGTERM");
58
+ }
59
+ catch {
60
+ clearTimeout(force);
61
+ done();
62
+ }
63
+ })));
64
+ };
65
+ try {
66
+ if (network.kind === "proxy")
67
+ bridge = await initializeLinuxNetworkBridge(network.httpPort, network.socksPort ?? network.httpPort);
68
+ // The runtime reads the temp folder it gives the command when it builds it (withRuntimeTemp, sandbox-runtime.ts).
69
+ if (spec.temp !== undefined)
70
+ process.env["CLAUDE_CODE_TMPDIR"] = spec.temp;
71
+ else
72
+ withRuntimeTemp(undefined, () => undefined);
73
+ // The command first says it started, on a descriptor of its own (3), before anything of the launch runs. A sandbox
74
+ // whose setup failed (bubblewrap couldn't mount over a path another launch removed after the runtime looked)
75
+ // never ran the launch, so it is set up again from a fresh look, a few times; one that started is never re-run.
76
+ const started = `printf 1 >&3 || exit 125; exec 3>&-; exec ${shellQuote([spec.file, ...spec.args])}`;
77
+ for (let attempt = 1;; attempt++) {
78
+ // Stopped during setup or between attempts: nothing starts.
79
+ if (stopping)
80
+ return STOPPED;
81
+ const gone = missing();
82
+ if (gone !== null)
83
+ throw new Error(`the sandbox can't be made here: ${gone}`);
84
+ const command = await wrapCommandWithSandboxLinux({
85
+ command: started,
86
+ needsNetworkRestriction: network.kind === "proxy",
87
+ ...(bridge === null || network.kind !== "proxy" ? {} : {
88
+ httpSocketPath: bridge.httpSocketPath, socksSocketPath: bridge.socksSocketPath, httpProxyPort: network.httpPort,
89
+ ...(network.socksPort === undefined ? {} : { socksProxyPort: network.socksPort }),
90
+ ...(network.token === undefined ? {} : { proxyAuthToken: network.token }),
91
+ }),
92
+ readConfig: spec.terms.readConfig,
93
+ writeConfig: spec.terms.writeConfig,
94
+ });
95
+ // bubblewrap, and the runtime's seccomp filter inside it: anything less is not this sandbox.
96
+ if (!/\bbwrap\b/.test(command) || !command.includes("apply-seccomp"))
97
+ throw new Error("the sandbox runtime returned a command without its sandbox or its seccomp filter");
98
+ if (stopping)
99
+ return STOPPED;
100
+ const run = spawn("/bin/sh", ["-c", command], { stdio: ["inherit", "inherit", "pipe", "pipe"] });
101
+ child = run;
102
+ let ran = false;
103
+ const held = [];
104
+ run.stdio[3].on("data", () => { if (!ran) {
105
+ ran = true;
106
+ for (const one of held.splice(0))
107
+ process.stderr.write(one);
108
+ } });
109
+ run.stdio[3].on("error", () => undefined);
110
+ run.stderr.on("data", (chunk) => { if (ran)
111
+ process.stderr.write(chunk);
112
+ else
113
+ held.push(chunk); });
114
+ const status = await new Promise(done => {
115
+ run.on("error", () => done(REFUSED));
116
+ run.on("close", (code, signal) => done(code ?? (signal === null ? 1 : 128 + ({ SIGKILL: 9, SIGTERM: 15, SIGINT: 2, SIGHUP: 1 }[signal] ?? 1))));
117
+ });
118
+ if (!ran && stopping)
119
+ return STOPPED;
120
+ if (ran || attempt >= 3) {
121
+ if (!ran)
122
+ for (const one of held)
123
+ process.stderr.write(one);
124
+ return status;
125
+ }
126
+ try {
127
+ cleanupBwrapMountPoints();
128
+ }
129
+ catch { /* the next look is fresh either way */ }
130
+ await new Promise(done => setTimeout(done, 50 * attempt));
131
+ }
132
+ }
133
+ catch (error) {
134
+ if (child === null) {
135
+ process.stderr.write(`toolroll sandbox: nothing was started: ${error instanceof Error ? error.message : String(error)}\n`);
136
+ return REFUSED;
137
+ }
138
+ return 1;
139
+ }
140
+ finally {
141
+ try {
142
+ cleanupBwrapMountPoints();
143
+ }
144
+ catch { /* best effort: the runtime's own exit handler retries */ }
145
+ await stopBridge();
146
+ }
147
+ }
148
+ void main().then(code => process.exit(code));
@@ -0,0 +1,62 @@
1
+ import type { FsReadRestrictionConfig, FsWriteRestrictionConfig } from "@anthropic-ai/sandbox-runtime";
2
+ /** The engine and its exact version, as the console and the words say it. */
3
+ export declare const SANDBOX_RUNTIME: {
4
+ name: string;
5
+ version: string;
6
+ };
7
+ /** "Anthropic's sandbox-runtime 0.0.79": how every surface names the engine. */
8
+ export declare const sandboxRuntimeWords: () => string;
9
+ /**
10
+ * One launch's sandbox, as the runtime's own config: what it may not read (and the exact paths read again inside
11
+ * those), where it may write (and what stays unwritable there), the extra macOS services it may look up, and its
12
+ * network (open, or only through this run's proxy; a tool broker rides the runtime's second relay).
13
+ */
14
+ export type SrtTerms = {
15
+ readConfig: FsReadRestrictionConfig;
16
+ writeConfig: FsWriteRestrictionConfig;
17
+ allowMachLookup: string[];
18
+ network: {
19
+ kind: "open";
20
+ } | {
21
+ kind: "proxy";
22
+ httpPort: number;
23
+ socksPort?: number;
24
+ token?: string;
25
+ localServices: boolean;
26
+ unixSockets: string[];
27
+ };
28
+ };
29
+ /** A deny-by-default profile, as the runtime's are: inside another sandbox one can't be applied (an allow-by-default one
30
+ * still can, so it proves nothing). */
31
+ export declare const SEATBELT_PROBE = "(version 1)(deny default)(allow process-exec)(allow process-fork)(allow file-read*)(allow sysctl-read)(allow mach-lookup)";
32
+ /** Why the runtime can't sandbox here, or null when it can (checked before every launch; nothing runs bare). */
33
+ export declare function sandboxRuntimeProblem(platform?: NodeJS.Platform): string | null;
34
+ /** POSIX shell words. */
35
+ export declare function shellQuote(words: readonly string[]): string;
36
+ /**
37
+ * The runtime sets a sandboxed command's TMPDIR itself (to $CLAUDE_CODE_TMPDIR of the process asking, or /tmp/claude):
38
+ * the launch's own temp folder when it has one, so it keeps it; otherwise /tmp/claude, made if missing.
39
+ */
40
+ export declare function withRuntimeTemp<T>(temp: string | undefined, make: () => T): T;
41
+ /** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
42
+ export declare function srtMacosCommand(file: string, args: readonly string[], terms: SrtTerms, cwd?: string, temp?: string): string;
43
+ /** The Linux helper (sandbox-launch.ts) and the environment variable its launch is handed in. */
44
+ export declare const SANDBOX_LAUNCH_SPEC = "TOOLROLL_SANDBOX_LAUNCH";
45
+ export declare const sandboxLaunchHelper: () => string;
46
+ /** What the Linux helper is given: the command, where it runs, and the terms. */
47
+ export type SandboxLaunchSpec = {
48
+ file: string;
49
+ args: string[];
50
+ cwd: string;
51
+ terms: SrtTerms;
52
+ temp?: string;
53
+ };
54
+ /**
55
+ * `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command>`; on Linux the helper,
56
+ * handed the launch in its environment (never its arguments, which other users can list).
57
+ */
58
+ export declare function srtSpawn(file: string, args: readonly string[], terms: SrtTerms, cwd?: string, temp?: string, platform?: NodeJS.Platform): {
59
+ file: string;
60
+ args: string[];
61
+ env: Record<string, string>;
62
+ };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The sandbox engine: Anthropic's sandbox-runtime (`@anthropic-ai/sandbox-runtime`, pinned in package.json). Every
3
+ * macOS Seatbelt profile and every Linux bubblewrap + seccomp invocation a fenced launch runs under is generated by
4
+ * its own code, per launch, from a small static config (read denies with exact re-allows, write roots with denies
5
+ * inside them, mach services, the run's proxy). Toolroll adds only what that config says (agent-fence.ts srtTermsOf);
6
+ * the rules themselves, their baseline and their limits are the sandbox runtime's.
7
+ *
8
+ * macOS: its profile builder runs in this process (it is synchronous), with this process's working folder set to the
9
+ * launch's for the call, because the runtime anchors its mandatory write denies (.git/hooks, .vscode, shell profiles)
10
+ * at the working folder. Linux: its builder is asynchronous, anchored at the working folder too, and leaves mount
11
+ * points to clean up after the command, so each Linux launch runs through a small helper process
12
+ * (sandbox-launch.ts) that changes into the launch's folder, asks the runtime for the command, runs it and cleans up.
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
16
+ import { createRequire } from "node:module";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { wrapCommandWithSandboxMacOS } from "@anthropic-ai/sandbox-runtime/dist/sandbox/macos-sandbox-utils.js";
20
+ import { checkLinuxDependencies, getLinuxDependencyStatus } from "@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js";
21
+ const require = createRequire(import.meta.url);
22
+ /** The engine and its exact version, as the console and the words say it. */
23
+ export const SANDBOX_RUNTIME = (() => {
24
+ const name = "@anthropic-ai/sandbox-runtime";
25
+ try {
26
+ const manifest = JSON.parse(readFileSync(join(dirname(require.resolve(name)), "..", "package.json"), "utf8"));
27
+ return { name, version: typeof manifest.version === "string" ? manifest.version : "unknown" };
28
+ }
29
+ catch {
30
+ return { name, version: "unknown" };
31
+ }
32
+ })();
33
+ /** "Anthropic's sandbox-runtime 0.0.79": how every surface names the engine. */
34
+ export const sandboxRuntimeWords = () => `Anthropic's sandbox-runtime ${SANDBOX_RUNTIME.version}`;
35
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
36
+ /** A deny-by-default profile, as the runtime's are: inside another sandbox one can't be applied (an allow-by-default one
37
+ * still can, so it proves nothing). */
38
+ export const SEATBELT_PROBE = "(version 1)(deny default)(allow process-exec)(allow process-fork)(allow file-read*)(allow sysctl-read)(allow mach-lookup)";
39
+ /** Why the runtime can't sandbox here, or null when it can (checked before every launch; nothing runs bare). */
40
+ export function sandboxRuntimeProblem(platform = process.platform) {
41
+ if (platform === "darwin") {
42
+ if (!existsSync(SANDBOX_EXEC))
43
+ return "this Mac has no sandbox-exec";
44
+ const probe = spawnSync(SANDBOX_EXEC, ["-p", SEATBELT_PROBE, "/usr/bin/true"], { timeout: 5_000, stdio: "ignore" });
45
+ return probe.status === 0 ? null : "sandbox-exec can't start a sandbox here (is this already inside one?)";
46
+ }
47
+ if (platform === "linux") {
48
+ const checked = checkLinuxDependencies();
49
+ if (checked.errors.length > 0)
50
+ return checked.errors.join("; ");
51
+ if (!getLinuxDependencyStatus().hasSeccompApply)
52
+ return "the sandbox runtime's seccomp helper isn't available for this processor";
53
+ // The runtime finds nested protected names (a subfolder's .git/hooks, .vscode, shell profiles) with ripgrep.
54
+ if (spawnSync("rg", ["--version"], { timeout: 5_000, stdio: "ignore" }).status !== 0)
55
+ return "ripgrep (rg) is not installed";
56
+ const probe = spawnSync("bwrap", ["--ro-bind", "/", "/", "--unshare-pid", "--unshare-user", "--proc", "/proc", "--new-session", "--", "true"], { timeout: 5_000, stdio: "ignore" });
57
+ return probe.status === 0 ? null : "bubblewrap can't start a sandbox here (unprivileged user namespaces may be off)";
58
+ }
59
+ return `the sandbox runtime has no sandbox for ${platform}`;
60
+ }
61
+ /** POSIX shell words. */
62
+ export function shellQuote(words) {
63
+ return words.map(word => /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`).join(" ");
64
+ }
65
+ /**
66
+ * The runtime sets a sandboxed command's TMPDIR itself (to $CLAUDE_CODE_TMPDIR of the process asking, or /tmp/claude):
67
+ * the launch's own temp folder when it has one, so it keeps it; otherwise /tmp/claude, made if missing.
68
+ */
69
+ export function withRuntimeTemp(temp, make) {
70
+ const before = { code: process.env["CLAUDE_CODE_TMPDIR"], old: process.env["CLAUDE_TMPDIR"] };
71
+ delete process.env["CLAUDE_TMPDIR"];
72
+ if (temp !== undefined && temp !== "")
73
+ process.env["CLAUDE_CODE_TMPDIR"] = temp;
74
+ else {
75
+ delete process.env["CLAUDE_CODE_TMPDIR"];
76
+ try {
77
+ mkdirSync("/tmp/claude", { recursive: true, mode: 0o700 });
78
+ }
79
+ catch { /* someone else's: writes there fail */ }
80
+ }
81
+ try {
82
+ return make();
83
+ }
84
+ finally {
85
+ if (before.code === undefined)
86
+ delete process.env["CLAUDE_CODE_TMPDIR"];
87
+ else
88
+ process.env["CLAUDE_CODE_TMPDIR"] = before.code;
89
+ if (before.old !== undefined)
90
+ process.env["CLAUDE_TMPDIR"] = before.old;
91
+ }
92
+ }
93
+ /** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
94
+ export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
95
+ const network = terms.network;
96
+ const params = {
97
+ command: shellQuote([file, ...args]),
98
+ needsNetworkRestriction: network.kind === "proxy",
99
+ ...(network.kind === "proxy" ? {
100
+ httpProxyPort: network.httpPort,
101
+ ...(network.socksPort === undefined ? {} : { socksProxyPort: network.socksPort }),
102
+ ...(network.token === undefined ? {} : { proxyAuthToken: network.token }),
103
+ allowLocalBinding: network.localServices,
104
+ ...(network.unixSockets.length === 0 ? {} : { allowUnixSockets: network.unixSockets }),
105
+ } : {}),
106
+ ...(terms.allowMachLookup.length === 0 ? {} : { allowMachLookup: terms.allowMachLookup }),
107
+ readConfig: terms.readConfig,
108
+ writeConfig: terms.writeConfig,
109
+ };
110
+ // The runtime anchors its mandatory write denies at this process's working folder: the launch's, for this call.
111
+ const before = process.cwd();
112
+ process.chdir(cwd);
113
+ try {
114
+ return withRuntimeTemp(temp, () => wrapCommandWithSandboxMacOS(params));
115
+ }
116
+ finally {
117
+ process.chdir(before);
118
+ }
119
+ }
120
+ /** The Linux helper (sandbox-launch.ts) and the environment variable its launch is handed in. */
121
+ export const SANDBOX_LAUNCH_SPEC = "TOOLROLL_SANDBOX_LAUNCH";
122
+ export const sandboxLaunchHelper = () => {
123
+ const beside = fileURLToPath(new URL("./sandbox-launch.js", import.meta.url));
124
+ // Run from the sources (the test suite), the built helper is the package's own.
125
+ return existsSync(beside) ? beside : fileURLToPath(new URL("../dist/sandbox-launch.js", import.meta.url));
126
+ };
127
+ /**
128
+ * `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command>`; on Linux the helper,
129
+ * handed the launch in its environment (never its arguments, which other users can list).
130
+ */
131
+ export function srtSpawn(file, args, terms, cwd = process.cwd(), temp, platform = process.platform) {
132
+ if (platform === "darwin") {
133
+ const command = srtMacosCommand(file, args, terms, cwd, temp);
134
+ if (!command.includes(SANDBOX_EXEC))
135
+ throw new Error("the sandbox runtime returned the command unsandboxed");
136
+ return { file: "/bin/sh", args: ["-c", command], env: {} };
137
+ }
138
+ const spec = { file, args: [...args], cwd, terms, ...(temp === undefined ? {} : { temp }) };
139
+ return { file: process.execPath, args: [sandboxLaunchHelper()], env: { [SANDBOX_LAUNCH_SPEC]: JSON.stringify(spec) } };
140
+ }
package/dist/scope.d.ts CHANGED
@@ -4,6 +4,7 @@ import { isAcpProfile, type AcpProviderId } from "./acp-agents.js";
4
4
  export { isAcpProfile };
5
5
  import { type NetworkProfile } from "./egress.js";
6
6
  import { type WritesMode } from "./writes-mode.js";
7
+ import { type CheckAccess } from "./check-access.js";
7
8
  import { type EvidenceKind } from "./contracts/acceptance-terms.js";
8
9
  import { type AcceptanceCriterion, type AcceptanceProblem, type ScopeTerms } from "./contracts/scope.js";
9
10
  import type { Store, Mutation } from "./store.js";
@@ -78,6 +79,15 @@ export type ClaudeProfile = {
78
79
  /** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
79
80
  * write allowlists, which keep writing anywhere their fence allows. */
80
81
  writes?: WritesMode;
82
+ /** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
83
+ checkAccess?: Exclude<CheckAccess, "sandboxed">;
84
+ /** The fence policy it was sealed under (agent-fence.ts FencePolicy): 2 from 0.9.70 (the home folder read as in
85
+ * Anthropic's sandbox runtime, less `readDeny`); absent on approvals sealed before, which keep v1's home denies. */
86
+ fencePolicy?: 2;
87
+ /** What its builds and checks may not read (project-sandbox.ts), sealed when the project lists any. */
88
+ readDeny?: string[];
89
+ /** macOS services its builds may also reach (project-sandbox.ts), sealed when the project lists any. */
90
+ machLookup?: string[];
81
91
  };
82
92
  export type CodexShapedProfile = {
83
93
  provider: "codex" | "openrouter";
@@ -98,6 +108,15 @@ export type CodexShapedProfile = {
98
108
  /** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
99
109
  * write allowlists, which keep writing anywhere their fence allows. */
100
110
  writes?: WritesMode;
111
+ /** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
112
+ checkAccess?: Exclude<CheckAccess, "sandboxed">;
113
+ /** The fence policy it was sealed under (agent-fence.ts FencePolicy): 2 from 0.9.70 (the home folder read as in
114
+ * Anthropic's sandbox runtime, less `readDeny`); absent on approvals sealed before, which keep v1's home denies. */
115
+ fencePolicy?: 2;
116
+ /** What its builds and checks may not read (project-sandbox.ts), sealed when the project lists any. */
117
+ readDeny?: string[];
118
+ /** macOS services its builds may also reach (project-sandbox.ts), sealed when the project lists any. */
119
+ machLookup?: string[];
101
120
  };
102
121
  export type GeminiProfile = {
103
122
  provider: "gemini";
@@ -120,6 +139,15 @@ export type GeminiProfile = {
120
139
  /** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
121
140
  * write allowlists, which keep writing anywhere their fence allows. */
122
141
  writes?: WritesMode;
142
+ /** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
143
+ checkAccess?: Exclude<CheckAccess, "sandboxed">;
144
+ /** The fence policy it was sealed under (agent-fence.ts FencePolicy): 2 from 0.9.70 (the home folder read as in
145
+ * Anthropic's sandbox runtime, less `readDeny`); absent on approvals sealed before, which keep v1's home denies. */
146
+ fencePolicy?: 2;
147
+ /** What its builds and checks may not read (project-sandbox.ts), sealed when the project lists any. */
148
+ readDeny?: string[];
149
+ /** macOS services its builds may also reach (project-sandbox.ts), sealed when the project lists any. */
150
+ machLookup?: string[];
123
151
  };
124
152
  /**
125
153
  * An agent over the Agent Client Protocol (acp-agents.ts, docs/plans/acp-adapter.md).
@@ -143,6 +171,15 @@ export type AcpProfile = {
143
171
  /** Where the build may write (writes-mode.ts), resolved when the scope was written; absent on approvals sealed before
144
172
  * write allowlists, which keep writing anywhere their fence allows. */
145
173
  writes?: WritesMode;
174
+ /** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
175
+ checkAccess?: Exclude<CheckAccess, "sandboxed">;
176
+ /** The fence policy it was sealed under (agent-fence.ts FencePolicy): 2 from 0.9.70 (the home folder read as in
177
+ * Anthropic's sandbox runtime, less `readDeny`); absent on approvals sealed before, which keep v1's home denies. */
178
+ fencePolicy?: 2;
179
+ /** What its builds and checks may not read (project-sandbox.ts), sealed when the project lists any. */
180
+ readDeny?: string[];
181
+ /** macOS services its builds may also reach (project-sandbox.ts), sealed when the project lists any. */
182
+ machLookup?: string[];
146
183
  };
147
184
  export type ExecutionProfile = ClaudeProfile | CodexShapedProfile | GeminiProfile | AcpProfile;
148
185
  /** Whether a profile's clock is a no-progress window (an ACP profile has both clocks; its overall one is a deadline). */