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.
- package/CHANGELOG.md +47 -0
- package/README.md +43 -17
- package/THIRD_PARTY_NOTICES.md +21 -1
- package/dist/agent-fence.d.ts +180 -115
- package/dist/agent-fence.js +413 -535
- package/dist/agentconfig.js +6 -1
- package/dist/browser/THIRD_PARTY_NOTICES.txt +21 -0
- package/dist/builder.js +24 -4
- package/dist/check-access.d.ts +6 -9
- package/dist/check-access.js +7 -10
- package/dist/coding-provider.d.ts +14 -0
- package/dist/coding-provider.js +33 -1
- package/dist/coding-workspace.d.ts +15 -0
- package/dist/coding-workspace.js +9 -2
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +4 -0
- package/dist/egress.d.ts +2 -0
- package/dist/egress.js +13 -4
- package/dist/exec.d.ts +17 -3
- package/dist/exec.js +54 -17
- package/dist/flow-actions.d.ts +1 -0
- package/dist/flow-actions.js +3 -2
- package/dist/flow-code.d.ts +31 -2
- package/dist/flow-code.js +57 -5
- package/dist/flow-steps.d.ts +4 -0
- package/dist/flow-steps.js +9 -5
- package/dist/flow-triggers.d.ts +4 -0
- package/dist/flow-triggers.js +8 -3
- package/dist/invoke.d.ts +18 -8
- package/dist/invoke.js +98 -115
- package/dist/operate.js +46 -0
- package/dist/project-delete-ui.d.ts +7 -6
- package/dist/project-delete-ui.js +36 -2
- package/dist/project-sandbox.d.ts +59 -0
- package/dist/project-sandbox.js +83 -0
- package/dist/project-tools.d.ts +29 -5
- package/dist/project-tools.js +43 -7
- package/dist/release.d.ts +1 -1
- package/dist/release.js +1 -0
- package/dist/sandbox-launch.d.ts +1 -0
- package/dist/sandbox-launch.js +148 -0
- package/dist/sandbox-runtime.d.ts +62 -0
- package/dist/sandbox-runtime.js +140 -0
- package/dist/scope.d.ts +28 -0
- package/dist/scope.js +25 -5
- package/dist/scout-browser-lock.d.ts +69 -0
- package/dist/scout-browser-lock.js +69 -0
- package/dist/scout-browser.d.ts +60 -1
- package/dist/scout-browser.js +200 -4
- package/dist/scout.d.ts +9 -4
- package/dist/scout.js +14 -4
- package/dist/serve.js +3 -1
- package/dist/server/render-tasks.js +4 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +27 -0
- package/dist/store.d.ts +31 -1
- package/dist/store.js +83 -4
- package/dist/subagent-tools.d.ts +1 -0
- package/dist/subagent-tools.js +7 -5
- package/dist/tool-broker.d.ts +2 -1
- package/dist/tool-broker.js +2 -2
- package/dist/tool-launcher.js +10 -16
- package/package.json +2 -1
- package/scripts/provider-canary.mjs +5 -6
- package/dist/egress-bridge.d.ts +0 -17
- package/dist/egress-bridge.js +0 -111
|
@@ -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
|
+
}
|
package/dist/project-tools.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/project-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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));
|
|
@@ -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
|
+
};
|