toolroll 0.9.69 → 0.9.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +43 -17
  3. package/THIRD_PARTY_NOTICES.md +21 -1
  4. package/dist/agent-fence.d.ts +184 -115
  5. package/dist/agent-fence.js +418 -535
  6. package/dist/agentconfig.js +6 -1
  7. package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
  8. package/dist/builder.js +24 -4
  9. package/dist/check-access.d.ts +6 -9
  10. package/dist/check-access.js +8 -11
  11. package/dist/coding-provider.d.ts +14 -0
  12. package/dist/coding-provider.js +33 -1
  13. package/dist/coding-workspace.d.ts +15 -0
  14. package/dist/coding-workspace.js +9 -2
  15. package/dist/contracts/console-api.d.ts +1 -0
  16. package/dist/contracts/console-api.js +1 -0
  17. package/dist/doctor.d.ts +7 -0
  18. package/dist/doctor.js +4 -0
  19. package/dist/egress.d.ts +2 -0
  20. package/dist/egress.js +13 -4
  21. package/dist/exec.d.ts +17 -3
  22. package/dist/exec.js +54 -17
  23. package/dist/flow-actions.d.ts +1 -0
  24. package/dist/flow-actions.js +3 -2
  25. package/dist/flow-code.d.ts +31 -2
  26. package/dist/flow-code.js +57 -5
  27. package/dist/flow-steps.d.ts +4 -0
  28. package/dist/flow-steps.js +9 -5
  29. package/dist/flow-triggers.d.ts +4 -0
  30. package/dist/flow-triggers.js +8 -3
  31. package/dist/invoke.d.ts +25 -8
  32. package/dist/invoke.js +115 -115
  33. package/dist/operate.js +46 -0
  34. package/dist/project-delete-ui.d.ts +7 -6
  35. package/dist/project-delete-ui.js +37 -3
  36. package/dist/project-sandbox.d.ts +59 -0
  37. package/dist/project-sandbox.js +83 -0
  38. package/dist/project-tools.d.ts +29 -5
  39. package/dist/project-tools.js +43 -7
  40. package/dist/release.d.ts +1 -1
  41. package/dist/release.js +1 -0
  42. package/dist/sandbox-launch.d.ts +1 -0
  43. package/dist/sandbox-launch.js +148 -0
  44. package/dist/sandbox-runtime.d.ts +109 -0
  45. package/dist/sandbox-runtime.js +281 -0
  46. package/dist/scope.d.ts +28 -0
  47. package/dist/scope.js +25 -5
  48. package/dist/scout-browser-lock.d.ts +69 -0
  49. package/dist/scout-browser-lock.js +69 -0
  50. package/dist/scout-browser.d.ts +60 -1
  51. package/dist/scout-browser.js +200 -4
  52. package/dist/scout.d.ts +9 -4
  53. package/dist/scout.js +14 -4
  54. package/dist/serve.js +3 -1
  55. package/dist/server/render-tasks.js +4 -1
  56. package/dist/server/route-table.js +1 -0
  57. package/dist/server/settings.js +27 -0
  58. package/dist/store.d.ts +31 -1
  59. package/dist/store.js +83 -4
  60. package/dist/subagent-tools.d.ts +1 -0
  61. package/dist/subagent-tools.js +7 -5
  62. package/dist/tool-broker.d.ts +2 -1
  63. package/dist/tool-broker.js +2 -2
  64. package/dist/tool-launcher.js +10 -16
  65. package/package.json +2 -1
  66. package/scripts/provider-canary.mjs +5 -6
  67. package/dist/egress-bridge.d.ts +0 -17
  68. package/dist/egress-bridge.js +0 -111
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Settings → Project: what Toolroll holds for a project, and deleting
3
+ * it. An instance operator deletes in two steps: type the project's name,
4
+ * then read exactly what goes and confirm with their password (or a fresh
5
+ * sign-in with the identity provider). The repository itself stays.
6
+ */
7
+ import { sandboxRuntimeWords } from "./sandbox-runtime.js";
1
8
  import { CHECK_ACCESS_WARNINGS, signInHostsMissing } from "./check-access.js";
2
9
  import { html, postForm, reauth } from "./html.js";
3
10
  import { holdingsWords } from "./project-delete.js";
@@ -49,6 +56,33 @@ function writesHtml(view) {
49
56
  <button type="submit">Save</button>`, { hidden: { repo: view.repo } });
50
57
  return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<details><summary>Change</summary>${form}</details></section>`;
51
58
  }
59
+ /** Home folder (project-sandbox.ts): what builds and checks can read there, said once; the paths they can't, and the
60
+ * advanced list (macOS services), changed under Change with the approver's password. */
61
+ function sandboxHtml(view) {
62
+ const sandbox = view.sandbox;
63
+ if (sandbox === undefined)
64
+ return "";
65
+ const { readDeny, machLookup } = sandbox.setting;
66
+ const said = readDeny.length === 0
67
+ ? html `<p>Builds and checks can read your home folder, as in Anthropic's sandbox runtime. Toolroll's own folders stay out of reach; other tasks' work is read, never written.</p>`
68
+ : html `<p>Builds and checks can read your home folder except <strong>${readDeny.length === 1 ? readDeny[0] : `${readDeny.length} paths`}</strong>.</p>`;
69
+ const extra = machLookup.length === 0 ? "" : html `<p class="meta">${machLookup.length} macOS ${machLookup.length === 1 ? "service" : "services"} allowed</p>`;
70
+ // The engine, named where the sandbox is set (sandbox-runtime.ts).
71
+ const engine = html `<p class="meta" data-sandbox-engine>Builds, checks and flows run in ${sandboxRuntimeWords()}.</p>`;
72
+ const head = html `<h2 id="sandbox-title">Home folder</h2>${said}${extra}${engine}`;
73
+ if (!sandbox.canChange)
74
+ return html `<section class="build-access sandbox-setting" id="sandbox" aria-labelledby="sandbox-title">${head}<p class="meta">An approver for this project can change this.</p></section>`;
75
+ const form = postForm("/settings/project/sandbox", html `
76
+ <label>Paths builds can't read<textarea name="read-deny" rows="4" placeholder="~/.ssh&#10;~/.aws">${readDeny.join("\n")}</textarea></label>
77
+ <p class="meta">One per line: <code>~/…</code> or a full path.</p>
78
+ <details><summary>Advanced</summary>
79
+ <label>macOS services builds may also use<textarea name="mach" rows="2">${machLookup.join("\n")}</textarea></label>
80
+ </details>
81
+ <p class="meta">Tasks scoped from now on use this; tasks already approved keep what they were approved with.</p>
82
+ ${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}
83
+ <button type="submit">Save</button>`, { hidden: { repo: view.repo } });
84
+ return html `<section class="build-access sandbox-setting" id="sandbox" aria-labelledby="sandbox-title">${head}<details><summary>Change</summary>${form}</details></section>`;
85
+ }
52
86
  /** Check access (check-access.ts): how far the project's checks reach, said once. Collapsed: the level, what it means
53
87
  * when it isn't the default, and who changed it. Inside Change: a line per level, and each wider level's warning under it
54
88
  * only while it is chosen. An instance operator widens it with a password typed now; an approver narrows it. */
@@ -62,7 +96,7 @@ function checkAccessHtml(view) {
62
96
  const setting = unavailable !== null && saved === "sign-ins" ? "sandboxed" : saved;
63
97
  const network = view.network?.setting ?? null;
64
98
  const names = { sandboxed: "Sandboxed", "sign-ins": "Sandboxed, with model and GitHub sign-ins", unsandboxed: "Not sandboxed" };
65
- const meaning = { sandboxed: null, "sign-ins": "Checks can read your Claude, Codex and GitHub sign-ins.", unsandboxed: "Checks run with your full access to this machine and to Toolroll." };
99
+ const meaning = { sandboxed: null, "sign-ins": "Checks can read your Claude, Codex and GitHub sign-ins, and on a Mac your Keychain (any item every program may read).", unsandboxed: "Checks run with your full access to this machine and to Toolroll." };
66
100
  // The sign-ins never widen the network: how many hosts they'd still need, and where to add them (a change of its own).
67
101
  const missing = setting === "sign-ins" && network !== null ? signInHostsMissing(network) : [];
68
102
  const hosts = missing.length === 0 ? "" : html `<p class="meta" data-sign-in-hosts>${missing.length} sign-in ${missing.length === 1 ? "host isn't" : "hosts aren't"} allowed yet · <a href="#network">Add them</a></p><details class="hosts"><summary>Show hosts</summary><p class="tool-name">${missing.join(", ")}</p></details>`;
@@ -83,7 +117,7 @@ function checkAccessHtml(view) {
83
117
  };
84
118
  const open = network?.mode === "open";
85
119
  const form = postForm("/settings/project/check-access", html `
86
- ${option("sandboxed", "Can't read the files your sign-ins and credentials are kept in, Toolroll's folders or other projects.", null)}
120
+ ${option("sandboxed", "Reads your home folder, as in Anthropic's sandbox runtime, except the folders this project hides (Home folder, above). Never Toolroll's folders or other tasks' work.", null)}
87
121
  ${unavailable !== null ? option("sign-ins", unavailable, null) : option("sign-ins", `Also reads the files your Claude, Codex and GitHub sign-ins are kept in, on this project's network (${open ? "any host" : network === null || network.hosts.length === 0 ? "registries only" : `registries and ${network.hosts.length} ${network.hosts.length === 1 ? "host" : "hosts"}`}).`, CHECK_ACCESS_WARNINGS["sign-ins"])}
88
122
  ${option("unsandboxed", "For projects whose checks create sandboxes themselves, such as Toolroll's own.", CHECK_ACCESS_WARNINGS.unsandboxed)}
89
123
  ${open && unavailable === null ? html `<label class="check"><input type="checkbox" name="confirm-open" value="yes"> The network is open: with sign-ins, checks could send them to any host</label>` : ""}
@@ -170,7 +204,7 @@ export function projectSettingsHtml(view, notice) {
170
204
  const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
171
205
  ? html `<p class="path">${view.remoteUrl}</p>`
172
206
  : html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add &lt;project&gt; --remote &lt;url&gt;</p></details></section>`;
173
- const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
207
+ const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${sandboxHtml(view)}${checkAccessHtml(view)}${liveViewHtml(view)}`;
174
208
  if (!view.canDelete)
175
209
  return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
176
210
  const body = view.running.length > 0
@@ -0,0 +1,59 @@
1
+ export type ProjectSandbox = {
2
+ readDeny: string[];
3
+ machLookup: string[];
4
+ };
5
+ export declare const PROJECT_SANDBOX_DEFAULT: ProjectSandbox;
6
+ export declare const MAX_READ_DENY = 100;
7
+ export declare const MAX_MACH_LOOKUP = 30;
8
+ /** A read-deny entry: `~/…` (the home folder of whoever runs the build) or an absolute path; normalized, no `..`, no
9
+ * pattern; `~` or `/` alone never (that would deny everything a build needs). */
10
+ export declare function validReadDeny(one: string): boolean;
11
+ /** Services no project can add, whatever it asks: the keychain and security services, the pasteboard, Launch Services
12
+ * and Apple Events (what a build would open or script another application with), the privacy database, the window
13
+ * server. Each name or prefix here matches the service and everything beneath it. */
14
+ export declare const MACH_NEVER: readonly string[];
15
+ /** A mach service a project may add: an exact reverse-DNS name, or a prefix ending `.*` after at least three labels
16
+ * (`com.example.tool.*`); never one of MACH_NEVER, nor a prefix that would reach one. */
17
+ export declare function validMachLookup(one: string): boolean;
18
+ /** Entries as a setting keeps them: trimmed, distinct, sorted; refused whole when any is invalid or there are too many. */
19
+ export declare function parseSandboxList(lines: readonly string[], valid: (one: string) => boolean, max: number): {
20
+ ok: true;
21
+ list: string[];
22
+ } | {
23
+ ok: false;
24
+ problem: string;
25
+ };
26
+ export declare const parseReadDeny: (lines: readonly string[]) => {
27
+ ok: true;
28
+ list: string[];
29
+ } | {
30
+ ok: false;
31
+ problem: string;
32
+ };
33
+ export declare const parseMachLookup: (lines: readonly string[]) => {
34
+ ok: true;
35
+ list: string[];
36
+ } | {
37
+ ok: false;
38
+ problem: string;
39
+ };
40
+ /** A stored or sealed setting, read strictly: anything unreadable reads as the narrowest (no extra services) but keeps
41
+ * whatever read-denies are valid (never fewer than were asked for). */
42
+ export declare function sandboxOf(raw: {
43
+ readDeny?: unknown;
44
+ machLookup?: unknown;
45
+ }): ProjectSandbox;
46
+ /** A read-deny entry as the path it names for a launch whose home folder is `home`. */
47
+ export declare const readDenyPath: (one: string, home: string) => string;
48
+ /** What an approval's sandbox terms say, one line each, for every surface that asks for a yes (scope.ts describeScope,
49
+ * the console's scope view): the fence policy and its read denies always, the macOS services when there are any. */
50
+ export declare function sandboxTermsWords(profile: {
51
+ fencePolicy?: 2;
52
+ readDeny?: readonly string[];
53
+ machLookup?: readonly string[];
54
+ }): {
55
+ reads: string;
56
+ mach: string | null;
57
+ };
58
+ /** The setting in a sentence, for the CLI and the settings page. */
59
+ export declare function readDenyWords(list: readonly string[]): string;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A project's sandbox settings (docs/plans/sandbox-parity.md C9), an approver's, each sealed into new scopes:
3
+ *
4
+ * - `readDeny`: what its builds and checks may not read (Anthropic's sandbox runtime `denyRead`). Under fence policy v2
5
+ * the home folder is readable as in the sandbox runtime; this is how a project hides `~/.ssh` and the like again.
6
+ * Narrowing only: it applies to running launches' next start too (flows, coding sessions read it live).
7
+ * - `machLookup`: macOS services its builds may also reach (the sandbox runtime `allowMachLookup`), for toolchains the
8
+ * deny-default baseline breaks (Go's TLS, Playwright). Widening: sealed, and only a person's password yes seals it.
9
+ */
10
+ import { isAbsolute, normalize } from "node:path";
11
+ export const PROJECT_SANDBOX_DEFAULT = { readDeny: [], machLookup: [] };
12
+ export const MAX_READ_DENY = 100;
13
+ export const MAX_MACH_LOOKUP = 30;
14
+ /** A read-deny entry: `~/…` (the home folder of whoever runs the build) or an absolute path; normalized, no `..`, no
15
+ * pattern; `~` or `/` alone never (that would deny everything a build needs). */
16
+ export function validReadDeny(one) {
17
+ if (one.length === 0 || one.length > 400 || one.includes("\0") || /[*?[\]{}]/.test(one))
18
+ return false;
19
+ const path = one.startsWith("~/") ? one.slice(2) : isAbsolute(one) ? one.slice(1) : null;
20
+ if (path === null || path === "" || path.endsWith("/"))
21
+ return false;
22
+ return path.split("/").every(part => part !== "" && part !== "." && part !== "..") && normalize(`/${path}`) === `/${path}`;
23
+ }
24
+ /** Services no project can add, whatever it asks: the keychain and security services, the pasteboard, Launch Services
25
+ * and Apple Events (what a build would open or script another application with), the privacy database, the window
26
+ * server. Each name or prefix here matches the service and everything beneath it. */
27
+ export const MACH_NEVER = ["com.apple.security", "com.apple.SecurityServer", "com.apple.securityd", "com.apple.pasteboard",
28
+ "com.apple.coreservices.launchservicesd", "com.apple.lsd", "com.apple.coreservices.appleevents", "com.apple.tccd", "com.apple.windowserver",
29
+ "com.apple.CoreServices.coreservicesd"];
30
+ /** A mach service a project may add: an exact reverse-DNS name, or a prefix ending `.*` after at least three labels
31
+ * (`com.example.tool.*`); never one of MACH_NEVER, nor a prefix that would reach one. */
32
+ export function validMachLookup(one) {
33
+ if (one.length > 200)
34
+ return false;
35
+ const prefix = one.endsWith(".*");
36
+ const name = prefix ? one.slice(0, -2) : one;
37
+ const labels = name.split(".");
38
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_@-]+)+$/.test(name) || labels.length < (prefix ? 3 : 2))
39
+ return false;
40
+ const lower = name.toLowerCase();
41
+ return !MACH_NEVER.some(never => { const blocked = never.toLowerCase(); return lower === blocked || lower.startsWith(blocked) || (prefix && blocked.startsWith(`${lower}.`)); });
42
+ }
43
+ /** Entries as a setting keeps them: trimmed, distinct, sorted; refused whole when any is invalid or there are too many. */
44
+ export function parseSandboxList(lines, valid, max) {
45
+ const list = [...new Set(lines.map(one => one.trim()).filter(one => one !== ""))].sort();
46
+ const bad = list.find(one => !valid(one));
47
+ if (bad !== undefined)
48
+ return { ok: false, problem: `“${bad.slice(0, 80)}” isn't a valid entry` };
49
+ if (list.length > max)
50
+ return { ok: false, problem: `at most ${max} entries` };
51
+ return { ok: true, list };
52
+ }
53
+ export const parseReadDeny = (lines) => parseSandboxList(lines, validReadDeny, MAX_READ_DENY);
54
+ export const parseMachLookup = (lines) => parseSandboxList(lines, validMachLookup, MAX_MACH_LOOKUP);
55
+ /** A stored or sealed setting, read strictly: anything unreadable reads as the narrowest (no extra services) but keeps
56
+ * whatever read-denies are valid (never fewer than were asked for). */
57
+ export function sandboxOf(raw) {
58
+ const strings = (value) => Array.isArray(value) ? value.filter((one) => typeof one === "string") : [];
59
+ const readDeny = parseReadDeny(strings(raw.readDeny));
60
+ const mach = parseMachLookup(strings(raw.machLookup));
61
+ return {
62
+ readDeny: readDeny.ok ? readDeny.list : strings(raw.readDeny).filter(validReadDeny),
63
+ machLookup: mach.ok ? mach.list : [],
64
+ };
65
+ }
66
+ /** A read-deny entry as the path it names for a launch whose home folder is `home`. */
67
+ export const readDenyPath = (one, home) => one.startsWith("~/") ? `${home}/${one.slice(2)}` : one;
68
+ /** What an approval's sandbox terms say, one line each, for every surface that asks for a yes (scope.ts describeScope,
69
+ * the console's scope view): the fence policy and its read denies always, the macOS services when there are any. */
70
+ export function sandboxTermsWords(profile) {
71
+ const reads = profile.fencePolicy === 2
72
+ ? `your home folder, as in Anthropic's sandbox runtime${(profile.readDeny ?? []).length === 0 ? "" : `, except ${(profile.readDeny ?? []).join(", ")}`}; never Toolroll's own folders; other tasks' work read, never written`
73
+ : "not your home folder's credentials and agent settings (approved before home reads opened)";
74
+ return {
75
+ reads,
76
+ mach: (profile.machLookup ?? []).length === 0 ? null : `also reaches these macOS services: ${(profile.machLookup ?? []).join(", ")}`,
77
+ };
78
+ }
79
+ /** The setting in a sentence, for the CLI and the settings page. */
80
+ export function readDenyWords(list) {
81
+ return list.length === 0 ? "Builds and checks can read your home folder, as in Anthropic's sandbox runtime; Toolroll's own folders stay out of reach, and other tasks' work is read, never written."
82
+ : `Builds and checks can read your home folder except ${list.length === 1 ? list[0] : `${list.length} paths (${list.slice(0, 3).join(", ")}${list.length > 3 ? ", …" : ""})`}.`;
83
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Store, ToolActionInfo } from "./store.js";
2
2
  import { type BrokerOptions } from "./tool-broker.js";
3
- import type { FenceMethod } from "./agent-fence.js";
3
+ import { type FenceMethod, type LaunchFence } from "./agent-fence.js";
4
4
  import type { ProviderId } from "./contracts/provider.js";
5
5
  import { type SecretStoreKind } from "./tool-secret-store.js";
6
6
  import { type ToolSecret, type ToolSpec } from "./contracts/integration-metadata.js";
@@ -105,10 +105,11 @@ export type FoundTool = {
105
105
  */
106
106
  export declare function discoverTools(repo: string, codexServers: unknown[] | null, home?: string, environment?: NodeJS.ProcessEnv): FoundTool[];
107
107
  /** Start the server once, ask for its tools, and stop it: the proof a build can use it. */
108
- export declare function testTool(spec: ToolSpec, values: Record<string, string>, options?: {
108
+ export declare function testTool(spec: ToolSpec, values: Record<string, string>, options: {
109
109
  timeoutMs?: number;
110
110
  env?: NodeJS.ProcessEnv;
111
111
  omitEnv?: readonly string[];
112
+ server: ToolServer;
112
113
  }): Promise<{
113
114
  ok: true;
114
115
  tools: string[];
@@ -121,10 +122,11 @@ export declare function testTool(spec: ToolSpec, values: Record<string, string>,
121
122
  * its description, what input it takes, and whether it says it only reads.
122
123
  * A subagent is offered these, each under its own rule.
123
124
  */
124
- export declare function listProjectToolActions(spec: ToolSpec, values: Record<string, string>, options?: {
125
+ export declare function listProjectToolActions(spec: ToolSpec, values: Record<string, string>, options: {
125
126
  timeoutMs?: number;
126
127
  env?: NodeJS.ProcessEnv;
127
128
  omitEnv?: readonly string[];
129
+ server: ToolServer;
128
130
  }): Promise<{
129
131
  ok: true;
130
132
  actions: ToolActionInfo[];
@@ -146,10 +148,11 @@ export type ToolCall = {
146
148
  * "Use a tool" step. The answer's text parts are kept; a tool that says it
147
149
  * failed (isError) is an answer, not trouble reaching it.
148
150
  */
149
- export declare function callProjectTool(spec: ToolSpec, values: Record<string, string>, name: string, args: Record<string, unknown>, options?: {
151
+ export declare function callProjectTool(spec: ToolSpec, values: Record<string, string>, name: string, args: Record<string, unknown>, options: {
150
152
  timeoutMs?: number;
151
153
  env?: NodeJS.ProcessEnv;
152
154
  omitEnv?: readonly string[];
155
+ server: ToolServer;
153
156
  }): Promise<ToolCall>;
154
157
  /** The exact servers one launch gets, and those left out with why (`repo`: the project, from the run's record). */
155
158
  export type ToolLaunch = {
@@ -259,10 +262,11 @@ export declare function addToolTo(store: ToolWrites, repo: string, input: ToolSp
259
262
  /** Take one tool away from every build now, with its stored secrets. */
260
263
  export declare function removeToolFrom(store: ToolWrites, repo: string, name: string, by: string, now: Date, home?: string): boolean;
261
264
  /** Test one tool of a project and keep the answer on it. */
262
- export declare function testToolOf(store: ToolWrites, repo: string, name: string, now: Date, options?: {
265
+ export declare function testToolOf(store: ToolWrites & ToolServerStore, repo: string, name: string, now: Date, options?: {
263
266
  home?: string | undefined;
264
267
  timeoutMs?: number;
265
268
  omitEnv?: readonly string[];
269
+ available?: () => boolean;
266
270
  }): Promise<ToolTest | null>;
267
271
  /** Where a tool stands, in a few words: what a person reads first. */
268
272
  export declare function toolStanding(tool: ProjectTool, secretsSet: readonly string[]): {
@@ -289,3 +293,23 @@ export declare function toolSecretStorage(): {
289
293
  };
290
294
  /** A command line split into program and arguments, honouring "double" and 'single' quotes. */
291
295
  export declare function splitCommandLine(line: string): string[];
296
+ /** What a tool server started outside a build needs of the store: the plane's own records of its state and pools. */
297
+ export type ToolServerStore = Pick<Store, "databaseFile" | "listWorktrees" | "unfencedAgentsAllowed">;
298
+ /** How a tool server started outside a build runs (toolServerFor): inside `fence`, or with none where there is nothing to
299
+ * fence (a store with no database file) or an instance operator allowed unconfined agents; or not at all. */
300
+ export type ToolServer = {
301
+ fence: LaunchFence | null;
302
+ } | {
303
+ problem: string;
304
+ };
305
+ /**
306
+ * The server fence for a project tool started outside a build (docs/plans/sandbox-parity.md C2): discovery, its Test
307
+ * button, a flow's "Use a tool" step, a subagent's call. As the broker's servers are fenced: Toolroll's state, every
308
+ * other launch's folder and the checkout pools denied, and nothing a build writes (its project's caches) read, so no
309
+ * byte a build wrote can load into a process that holds the tool's secrets. Required: where this machine can't confine
310
+ * it, it doesn't start unless an instance operator allowed unconfined agents.
311
+ */
312
+ export declare function toolServerFor(store: ToolServerStore, repo: string, options?: {
313
+ home?: string;
314
+ available?: () => boolean;
315
+ }): ToolServer;
@@ -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. */
@@ -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));