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
@@ -0,0 +1,109 @@
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
+ /** macOS: the launch may reach the keychain's services (securityd). Only a build's agent and a check with sign-ins:
19
+ * every other launch is denied them (SEATBELT_NO_KEYCHAIN). */
20
+ keychain?: boolean;
21
+ network: {
22
+ kind: "open";
23
+ } | {
24
+ kind: "proxy";
25
+ httpPort: number;
26
+ socksPort?: number;
27
+ token?: string;
28
+ localServices: boolean;
29
+ unixSockets: string[];
30
+ };
31
+ };
32
+ /** A deny-by-default profile, as the runtime's are: inside another sandbox one can't be applied (an allow-by-default one
33
+ * still can, so it proves nothing). */
34
+ export declare const SEATBELT_PROBE = "(version 1)(deny default)(allow process-exec)(allow process-fork)(allow file-read*)(allow sysctl-read)(allow mach-lookup)";
35
+ /** Why the runtime can't sandbox here, or null when it can (checked before every launch; nothing runs bare). */
36
+ export declare function sandboxRuntimeProblem(platform?: NodeJS.Platform): string | null;
37
+ /** POSIX shell words. */
38
+ export declare function shellQuote(words: readonly string[]): string;
39
+ /**
40
+ * The runtime sets a sandboxed command's TMPDIR itself (to $CLAUDE_CODE_TMPDIR of the process asking, or /tmp/claude):
41
+ * the launch's own temp folder when it has one, so it keeps it; otherwise /tmp/claude, made if missing.
42
+ */
43
+ export declare function withRuntimeTemp<T>(temp: string | undefined, make: () => T): T;
44
+ /**
45
+ * Toolroll's own Seatbelt rules, appended to the end of the runtime's profile (Seatbelt takes the last rule that
46
+ * matches). Static: constants here, never made from a launch's data.
47
+ *
48
+ * Terminals: a pseudo-terminal of the launch's own (node-pty, `script`, a test that drives a shell), and no other. The
49
+ * runtime's `allowPty` opens reads and writes on every /dev/ttys device, the operator's own open terminals among them,
50
+ * and its baseline already reads them (a read steals what is typed there). macOS marks a terminal the sandboxed process
51
+ * made with the `com.apple.sandbox.pty` extension (for that terminal only: a device number used again later outside is
52
+ * not covered), so only those are opened by name. A terminal handed down open (a live terminal's own) stays usable.
53
+ */
54
+ export declare const SEATBELT_PTY: string;
55
+ /** Last in every profile: no other terminal opened by name, and no keystrokes pushed into any terminal (TIOCSTI,
56
+ * _IOW('t', 114, char)), its own controlling terminal included. */
57
+ export declare const SEATBELT_NO_OTHER_TERMINAL: string;
58
+ /**
59
+ * Last in every launch's profile but a build agent's and a check with sign-ins: the keychain's services. The runtime's
60
+ * baseline lets every sandboxed process ask securityd, so it reads the login keychain's items whose access list lets
61
+ * any program read them (anthropics/sandbox-runtime#755). Build agents keep it (Claude Code signs in from the keychain).
62
+ */
63
+ export declare const SEATBELT_NO_KEYCHAIN: string;
64
+ /** Toolroll's rules for a launch's terms, in order: its own terminal (unless its read denies cover the devices), then
65
+ * the denies that must come last. */
66
+ export declare function seatbeltRulesFor(terms: SrtTerms): string;
67
+ /** The runtime's own quoting (its utils/shell-quote.js): a word bare when nothing in it is special, otherwise in single
68
+ * quotes with a quote inside as '"'"'. */
69
+ export declare function srtQuote(words: readonly string[]): string;
70
+ /** The words of a line srtQuote made, or null for any other line (an operator, a second command, other quoting). */
71
+ export declare function srtWords(line: string): string[] | null;
72
+ /**
73
+ * `command` (the runtime's macOS invocation) with `rules` appended to its profile, made again from its words. The
74
+ * runtime's invocation is exactly `env [-u NAME]… [NAME=value]… /usr/bin/sandbox-exec -p <profile> <shell> -c
75
+ * <command>`, each word quoted its way, and `<command>` is `expected` when given. Any other line (an operator, a word
76
+ * before `env` or after the command, another program) is refused, so the launch doesn't run.
77
+ */
78
+ export declare function withSeatbeltRules(command: string, rules: string, expected?: string): string;
79
+ /** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
80
+ export declare function srtMacosCommand(file: string, args: readonly string[], terms: SrtTerms, cwd?: string, temp?: string): string;
81
+ /**
82
+ * macOS: what every sandboxed launch runs first, outside the sandbox, before it execs the runtime's command: a session of
83
+ * its own (setsid, syscall 147), so no terminal of whoever started Toolroll is its controlling terminal to reach. A
84
+ * launch that is a session leader already (a live terminal's program, its own terminal made for it) keeps that one; any
85
+ * other that still has a controlling terminal (getsid, syscall 310) is refused. Perl with -t reads neither PERL5OPT nor
86
+ * PERL5LIB, and -X keeps its taint warnings off the launch's output.
87
+ */
88
+ export declare const DETACH: readonly string[];
89
+ /** The Linux helper (sandbox-launch.ts) and the environment variable its launch is handed in. */
90
+ export declare const SANDBOX_LAUNCH_SPEC = "TOOLROLL_SANDBOX_LAUNCH";
91
+ export declare const sandboxLaunchHelper: () => string;
92
+ /** What the Linux helper is given: the command, where it runs, and the terms. */
93
+ export type SandboxLaunchSpec = {
94
+ file: string;
95
+ args: string[];
96
+ cwd: string;
97
+ terms: SrtTerms;
98
+ temp?: string;
99
+ };
100
+ /**
101
+ * `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command>` in a session of its own
102
+ * (DETACH); on Linux the helper,
103
+ * handed the launch in its environment (never its arguments, which other users can list).
104
+ */
105
+ export declare function srtSpawn(file: string, args: readonly string[], terms: SrtTerms, cwd?: string, temp?: string, platform?: NodeJS.Platform): {
106
+ file: string;
107
+ args: string[];
108
+ env: Record<string, string>;
109
+ };
@@ -0,0 +1,281 @@
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
+ * plus, on macOS, a few static rules of its own appended to the end of the profile (seatbeltRulesFor: a terminal of the
7
+ * launch's own and no other, no keystrokes pushed into a terminal, the keychain's services only for builds); the rest
8
+ * of the rules, their baseline and their limits are the runtime's.
9
+ *
10
+ * macOS: its profile builder runs in this process (it is synchronous), with this process's working folder set to the
11
+ * launch's for the call, because the runtime anchors its mandatory write denies (.git/hooks, .vscode, shell profiles)
12
+ * at the working folder. Linux: its builder is asynchronous, anchored at the working folder too, and leaves mount
13
+ * points to clean up after the command, so each Linux launch runs through a small helper process
14
+ * (sandbox-launch.ts) that changes into the launch's folder, asks the runtime for the command, runs it and cleans up.
15
+ */
16
+ import { spawnSync } from "node:child_process";
17
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
18
+ import { createRequire } from "node:module";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { wrapCommandWithSandboxMacOS } from "@anthropic-ai/sandbox-runtime/dist/sandbox/macos-sandbox-utils.js";
22
+ import { checkLinuxDependencies, getLinuxDependencyStatus } from "@anthropic-ai/sandbox-runtime/dist/sandbox/linux-sandbox-utils.js";
23
+ const require = createRequire(import.meta.url);
24
+ /** The engine and its exact version, as the console and the words say it. */
25
+ export const SANDBOX_RUNTIME = (() => {
26
+ const name = "@anthropic-ai/sandbox-runtime";
27
+ try {
28
+ const manifest = JSON.parse(readFileSync(join(dirname(require.resolve(name)), "..", "package.json"), "utf8"));
29
+ return { name, version: typeof manifest.version === "string" ? manifest.version : "unknown" };
30
+ }
31
+ catch {
32
+ return { name, version: "unknown" };
33
+ }
34
+ })();
35
+ /** "Anthropic's sandbox-runtime 0.0.79": how every surface names the engine. */
36
+ export const sandboxRuntimeWords = () => `Anthropic's sandbox-runtime ${SANDBOX_RUNTIME.version}`;
37
+ const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
38
+ /** A deny-by-default profile, as the runtime's are: inside another sandbox one can't be applied (an allow-by-default one
39
+ * still can, so it proves nothing). */
40
+ export const SEATBELT_PROBE = "(version 1)(deny default)(allow process-exec)(allow process-fork)(allow file-read*)(allow sysctl-read)(allow mach-lookup)";
41
+ /** Why the runtime can't sandbox here, or null when it can (checked before every launch; nothing runs bare). */
42
+ export function sandboxRuntimeProblem(platform = process.platform) {
43
+ if (platform === "darwin") {
44
+ if (!existsSync(SANDBOX_EXEC))
45
+ return "this Mac has no sandbox-exec";
46
+ const probe = spawnSync(SANDBOX_EXEC, ["-p", SEATBELT_PROBE, "/usr/bin/true"], { timeout: 5_000, stdio: "ignore" });
47
+ return probe.status === 0 ? null : "sandbox-exec can't start a sandbox here (is this already inside one?)";
48
+ }
49
+ if (platform === "linux") {
50
+ const checked = checkLinuxDependencies();
51
+ if (checked.errors.length > 0)
52
+ return checked.errors.join("; ");
53
+ if (!getLinuxDependencyStatus().hasSeccompApply)
54
+ return "the sandbox runtime's seccomp helper isn't available for this processor";
55
+ // The runtime finds nested protected names (a subfolder's .git/hooks, .vscode, shell profiles) with ripgrep.
56
+ if (spawnSync("rg", ["--version"], { timeout: 5_000, stdio: "ignore" }).status !== 0)
57
+ return "ripgrep (rg) is not installed";
58
+ const probe = spawnSync("bwrap", ["--ro-bind", "/", "/", "--unshare-pid", "--unshare-user", "--proc", "/proc", "--new-session", "--", "true"], { timeout: 5_000, stdio: "ignore" });
59
+ return probe.status === 0 ? null : "bubblewrap can't start a sandbox here (unprivileged user namespaces may be off)";
60
+ }
61
+ return `the sandbox runtime has no sandbox for ${platform}`;
62
+ }
63
+ /** POSIX shell words. */
64
+ export function shellQuote(words) {
65
+ return words.map(word => /^[A-Za-z0-9_@%+=:,./-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`).join(" ");
66
+ }
67
+ /**
68
+ * The runtime sets a sandboxed command's TMPDIR itself (to $CLAUDE_CODE_TMPDIR of the process asking, or /tmp/claude):
69
+ * the launch's own temp folder when it has one, so it keeps it; otherwise /tmp/claude, made if missing.
70
+ */
71
+ export function withRuntimeTemp(temp, make) {
72
+ const before = { code: process.env["CLAUDE_CODE_TMPDIR"], old: process.env["CLAUDE_TMPDIR"] };
73
+ delete process.env["CLAUDE_TMPDIR"];
74
+ if (temp !== undefined && temp !== "")
75
+ process.env["CLAUDE_CODE_TMPDIR"] = temp;
76
+ else {
77
+ delete process.env["CLAUDE_CODE_TMPDIR"];
78
+ try {
79
+ mkdirSync("/tmp/claude", { recursive: true, mode: 0o700 });
80
+ }
81
+ catch { /* someone else's: writes there fail */ }
82
+ }
83
+ try {
84
+ return make();
85
+ }
86
+ finally {
87
+ if (before.code === undefined)
88
+ delete process.env["CLAUDE_CODE_TMPDIR"];
89
+ else
90
+ process.env["CLAUDE_CODE_TMPDIR"] = before.code;
91
+ if (before.old !== undefined)
92
+ process.env["CLAUDE_TMPDIR"] = before.old;
93
+ }
94
+ }
95
+ /**
96
+ * Toolroll's own Seatbelt rules, appended to the end of the runtime's profile (Seatbelt takes the last rule that
97
+ * matches). Static: constants here, never made from a launch's data.
98
+ *
99
+ * Terminals: a pseudo-terminal of the launch's own (node-pty, `script`, a test that drives a shell), and no other. The
100
+ * runtime's `allowPty` opens reads and writes on every /dev/ttys device, the operator's own open terminals among them,
101
+ * and its baseline already reads them (a read steals what is typed there). macOS marks a terminal the sandboxed process
102
+ * made with the `com.apple.sandbox.pty` extension (for that terminal only: a device number used again later outside is
103
+ * not covered), so only those are opened by name. A terminal handed down open (a live terminal's own) stays usable.
104
+ */
105
+ export const SEATBELT_PTY = [
106
+ "; Toolroll: a pseudo-terminal the launch made itself",
107
+ "(allow pseudo-tty)",
108
+ "(allow file-read* file-write* file-ioctl (literal \"/dev/ptmx\"))",
109
+ "(allow file-read* file-write* file-ioctl (require-all (regex #\"^/dev/ttys[0-9]+$\") (extension \"com.apple.sandbox.pty\")))",
110
+ ].join("\n");
111
+ /** Last in every profile: no other terminal opened by name, and no keystrokes pushed into any terminal (TIOCSTI,
112
+ * _IOW('t', 114, char)), its own controlling terminal included. */
113
+ export const SEATBELT_NO_OTHER_TERMINAL = [
114
+ "; Toolroll: no other terminal, and nothing typed into one",
115
+ "(deny file-read-data file-write* (require-all (regex #\"^/dev/ttys[0-9]+$\") (require-not (extension \"com.apple.sandbox.pty\"))))",
116
+ "(deny file-ioctl (ioctl-command 2147578994))",
117
+ ].join("\n");
118
+ /**
119
+ * Last in every launch's profile but a build agent's and a check with sign-ins: the keychain's services. The runtime's
120
+ * baseline lets every sandboxed process ask securityd, so it reads the login keychain's items whose access list lets
121
+ * any program read them (anthropics/sandbox-runtime#755). Build agents keep it (Claude Code signs in from the keychain).
122
+ */
123
+ export const SEATBELT_NO_KEYCHAIN = [
124
+ "; Toolroll: no keychain",
125
+ "(deny mach-lookup (global-name \"com.apple.SecurityServer\") (global-name \"com.apple.securityd.xpc\"))",
126
+ ].join("\n");
127
+ /** Whether a read deny covers the launch's own terminal devices (/dev/ptmx, /dev/ttys*): then it gets none. */
128
+ function deniesTerminals(deny) {
129
+ const glob = deny.search(/[*?[]/);
130
+ const path = (glob < 0 ? deny : deny.slice(0, glob)).replace(/\/+$/, "");
131
+ const devices = ["/dev/ptmx", "/dev/ttys0"];
132
+ if (glob >= 0)
133
+ return devices.some(device => device.startsWith(path));
134
+ return path === "" || path === "/dev" || devices.includes(path) || /^\/dev\/ttys[0-9]*$/.test(path);
135
+ }
136
+ /** Toolroll's rules for a launch's terms, in order: its own terminal (unless its read denies cover the devices), then
137
+ * the denies that must come last. */
138
+ export function seatbeltRulesFor(terms) {
139
+ const denied = [...terms.readConfig.denyOnly, ...terms.readConfig.literalDenyOnly ?? []].some(one => deniesTerminals(typeof one === "string" ? one : String(one)));
140
+ return [...denied ? [] : [SEATBELT_PTY], SEATBELT_NO_OTHER_TERMINAL, ...terms.keychain === true ? [] : [SEATBELT_NO_KEYCHAIN]].join("\n");
141
+ }
142
+ const BARE_WORD = /^[A-Za-z0-9_./:@+,-][A-Za-z0-9_./:=@+,-]*$/;
143
+ /** The runtime's own quoting (its utils/shell-quote.js): a word bare when nothing in it is special, otherwise in single
144
+ * quotes with a quote inside as '"'"'. */
145
+ export function srtQuote(words) {
146
+ return words.map(word => word === "" ? "''" : BARE_WORD.test(word) ? word : `'${word.replace(/'/g, `'"'"'`)}'`).join(" ");
147
+ }
148
+ /** The words of a line srtQuote made, or null for any other line (an operator, a second command, other quoting). */
149
+ export function srtWords(line) {
150
+ const words = [];
151
+ let at = 0;
152
+ while (at < line.length) {
153
+ let word = "";
154
+ if (line[at] === "'") {
155
+ for (;;) {
156
+ const close = line.indexOf("'", at + 1);
157
+ if (close < 0)
158
+ return null;
159
+ word += line.slice(at + 1, close);
160
+ at = close + 1;
161
+ if (line.startsWith(`"'"'`, at)) {
162
+ word += "'";
163
+ at += 3;
164
+ continue;
165
+ }
166
+ break;
167
+ }
168
+ }
169
+ else {
170
+ const end = line.indexOf(" ", at);
171
+ word = line.slice(at, end < 0 ? line.length : end);
172
+ if (!BARE_WORD.test(word))
173
+ return null;
174
+ at += word.length;
175
+ }
176
+ words.push(word);
177
+ if (at === line.length)
178
+ break;
179
+ if (line[at] !== " " || at + 1 === line.length)
180
+ return null;
181
+ at += 1;
182
+ }
183
+ return words.length > 0 && srtQuote(words) === line ? words : null;
184
+ }
185
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
186
+ /**
187
+ * `command` (the runtime's macOS invocation) with `rules` appended to its profile, made again from its words. The
188
+ * runtime's invocation is exactly `env [-u NAME]… [NAME=value]… /usr/bin/sandbox-exec -p <profile> <shell> -c
189
+ * <command>`, each word quoted its way, and `<command>` is `expected` when given. Any other line (an operator, a word
190
+ * before `env` or after the command, another program) is refused, so the launch doesn't run.
191
+ */
192
+ export function withSeatbeltRules(command, rules, expected) {
193
+ if (rules.includes("'"))
194
+ throw new Error("a Seatbelt rule Toolroll adds can't hold a quote");
195
+ const shape = () => new Error("the sandbox runtime's command isn't the shape Toolroll knows");
196
+ const words = srtWords(command);
197
+ if (words === null || words[0] !== "env")
198
+ throw shape();
199
+ const exec = words.indexOf(SANDBOX_EXEC);
200
+ if (exec < 1 || words.length !== exec + 6 || words[exec + 1] !== "-p" || words[exec + 4] !== "-c")
201
+ throw shape();
202
+ for (let index = 1; index < exec; index += 1) {
203
+ if (words[index] === "-u") {
204
+ if (!ENV_NAME.test(words[index + 1] ?? ""))
205
+ throw shape();
206
+ index += 1;
207
+ continue;
208
+ }
209
+ const name = words[index].split("=", 1)[0];
210
+ if (!words[index].includes("=") || !ENV_NAME.test(name))
211
+ throw shape();
212
+ }
213
+ const profile = words[exec + 2];
214
+ if (!profile.startsWith("(version 1)\n(deny default"))
215
+ throw shape();
216
+ if (!/^\/[A-Za-z0-9_./-]+$/.test(words[exec + 3]))
217
+ throw shape();
218
+ if (expected !== undefined && words[exec + 5] !== expected)
219
+ throw shape();
220
+ return srtQuote([...words.slice(0, exec + 2), `${profile}\n${rules}\n`, ...words.slice(exec + 3)]);
221
+ }
222
+ /** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
223
+ export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
224
+ const network = terms.network;
225
+ const params = {
226
+ command: shellQuote([file, ...args]),
227
+ needsNetworkRestriction: network.kind === "proxy",
228
+ ...(network.kind === "proxy" ? {
229
+ httpProxyPort: network.httpPort,
230
+ ...(network.socksPort === undefined ? {} : { socksProxyPort: network.socksPort }),
231
+ ...(network.token === undefined ? {} : { proxyAuthToken: network.token }),
232
+ allowLocalBinding: network.localServices,
233
+ ...(network.unixSockets.length === 0 ? {} : { allowUnixSockets: network.unixSockets }),
234
+ } : {}),
235
+ ...(terms.allowMachLookup.length === 0 ? {} : { allowMachLookup: terms.allowMachLookup }),
236
+ readConfig: terms.readConfig,
237
+ writeConfig: terms.writeConfig,
238
+ };
239
+ // The runtime anchors its mandatory write denies at this process's working folder: the launch's, for this call.
240
+ const before = process.cwd();
241
+ process.chdir(cwd);
242
+ let command;
243
+ try {
244
+ command = withRuntimeTemp(temp, () => wrapCommandWithSandboxMacOS(params));
245
+ }
246
+ finally {
247
+ process.chdir(before);
248
+ }
249
+ return withSeatbeltRules(command, seatbeltRulesFor(terms), params.command);
250
+ }
251
+ /**
252
+ * macOS: what every sandboxed launch runs first, outside the sandbox, before it execs the runtime's command: a session of
253
+ * its own (setsid, syscall 147), so no terminal of whoever started Toolroll is its controlling terminal to reach. A
254
+ * launch that is a session leader already (a live terminal's program, its own terminal made for it) keeps that one; any
255
+ * other that still has a controlling terminal (getsid, syscall 310) is refused. Perl with -t reads neither PERL5OPT nor
256
+ * PERL5LIB, and -X keeps its taint warnings off the launch's output.
257
+ */
258
+ export const DETACH = ["-t", "-X", "-e",
259
+ 'syscall(147) != -1 or syscall(310, 0) == $$ or !open(my $t, "<", "/dev/tty") or die "toolroll: the sandboxed launch kept a terminal\\n"; exec { $ARGV[0] } @ARGV; die "toolroll: the sandbox could not start: $!\\n";'];
260
+ /** The Linux helper (sandbox-launch.ts) and the environment variable its launch is handed in. */
261
+ export const SANDBOX_LAUNCH_SPEC = "TOOLROLL_SANDBOX_LAUNCH";
262
+ export const sandboxLaunchHelper = () => {
263
+ const beside = fileURLToPath(new URL("./sandbox-launch.js", import.meta.url));
264
+ // Run from the sources (the test suite), the built helper is the package's own.
265
+ return existsSync(beside) ? beside : fileURLToPath(new URL("../dist/sandbox-launch.js", import.meta.url));
266
+ };
267
+ /**
268
+ * `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command>` in a session of its own
269
+ * (DETACH); on Linux the helper,
270
+ * handed the launch in its environment (never its arguments, which other users can list).
271
+ */
272
+ export function srtSpawn(file, args, terms, cwd = process.cwd(), temp, platform = process.platform) {
273
+ if (platform === "darwin") {
274
+ const command = srtMacosCommand(file, args, terms, cwd, temp);
275
+ if (!command.includes(SANDBOX_EXEC))
276
+ throw new Error("the sandbox runtime returned the command unsandboxed");
277
+ return { file: "/usr/bin/perl", args: [...DETACH, "/bin/sh", "-c", command], env: {} };
278
+ }
279
+ const spec = { file, args: [...args], cwd, terms, ...(temp === undefined ? {} : { temp }) };
280
+ return { file: process.execPath, args: [sandboxLaunchHelper()], env: { [SANDBOX_LAUNCH_SPEC]: JSON.stringify(spec) } };
281
+ }
package/dist/scope.d.ts CHANGED
@@ -81,6 +81,13 @@ export type ClaudeProfile = {
81
81
  writes?: WritesMode;
82
82
  /** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
83
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[];
84
91
  };
85
92
  export type CodexShapedProfile = {
86
93
  provider: "codex" | "openrouter";
@@ -103,6 +110,13 @@ export type CodexShapedProfile = {
103
110
  writes?: WritesMode;
104
111
  /** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
105
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[];
106
120
  };
107
121
  export type GeminiProfile = {
108
122
  provider: "gemini";
@@ -127,6 +141,13 @@ export type GeminiProfile = {
127
141
  writes?: WritesMode;
128
142
  /** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
129
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[];
130
151
  };
131
152
  /**
132
153
  * An agent over the Agent Client Protocol (acp-agents.ts, docs/plans/acp-adapter.md).
@@ -152,6 +173,13 @@ export type AcpProfile = {
152
173
  writes?: WritesMode;
153
174
  /** How far its checks reach beyond the check fence (check-access.ts), sealed when wider than sandboxed; absent: sandboxed. */
154
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[];
155
183
  };
156
184
  export type ExecutionProfile = ClaudeProfile | CodexShapedProfile | GeminiProfile | AcpProfile;
157
185
  /** Whether a profile's clock is a no-progress window (an ACP profile has both clocks; its overall one is a deadline). */
package/dist/scope.js CHANGED
@@ -42,6 +42,7 @@ import { createHash, randomBytes, scryptSync, timingSafeEqual } from "node:crypt
42
42
  import { networkFromValue, networkWords } from "./egress.js";
43
43
  import { isWritesMode, writesWords } from "./writes-mode.js";
44
44
  import { checkAccessWords, isCheckAccess } from "./check-access.js";
45
+ import { MAX_MACH_LOOKUP, MAX_READ_DENY, sandboxTermsWords, validMachLookup, validReadDeny } from "./project-sandbox.js";
45
46
  import { z } from "zod";
46
47
  import {} from "./contracts/acceptance-terms.js";
47
48
  import { parseContract } from "./contracts/contract.js";
@@ -360,9 +361,26 @@ export function profileFromJson(json) {
360
361
  if (sealedAccess !== undefined && (sealedAccess === "sandboxed" || !isCheckAccess(sealedAccess)))
361
362
  return null;
362
363
  const checkAccess = sealedAccess;
363
- const withNetwork = { ...(network === undefined ? {} : { network }), ...(writes === undefined ? {} : { writes }), ...(checkAccess === undefined ? {} : { checkAccess }) };
364
+ // Sandbox parity (v133): the policy version is exactly 2 when present; the lists, when present, are non-empty, sorted,
365
+ // distinct and valid (project-sandbox.ts), as only this code writes them.
366
+ const policy = p["fencePolicy"];
367
+ if (policy !== undefined && policy !== 2)
368
+ return null;
369
+ const sealedList = (key, valid, max) => {
370
+ const list = p[key];
371
+ if (list === undefined)
372
+ return undefined;
373
+ return Array.isArray(list) && list.length > 0 && list.length <= max && list.every(one => typeof one === "string" && valid(one))
374
+ && list.every((one, at) => at === 0 || list[at - 1] < one) ? [...list] : null;
375
+ };
376
+ const readDeny = sealedList("readDeny", validReadDeny, MAX_READ_DENY);
377
+ const machLookup = sealedList("machLookup", validMachLookup, MAX_MACH_LOOKUP);
378
+ if (readDeny === null || machLookup === null)
379
+ return null;
380
+ const withNetwork = { ...(network === undefined ? {} : { network }), ...(writes === undefined ? {} : { writes }), ...(checkAccess === undefined ? {} : { checkAccess }),
381
+ ...(policy === undefined ? {} : { fencePolicy: 2 }), ...(readDeny === undefined ? {} : { readDeny }), ...(machLookup === undefined ? {} : { machLookup }) };
364
382
  if (p["provider"] === "claude") {
365
- if (exactKeys(p, [...PROVIDER_KEYS, "permissionArgv"], ["timeoutKind", "network", "writes", "checkAccess"]) &&
383
+ if (exactKeys(p, [...PROVIDER_KEYS, "permissionArgv"], ["timeoutKind", "network", "writes", "checkAccess", "fencePolicy", "readDeny", "machLookup"]) &&
366
384
  str(p["model"]) &&
367
385
  (p["permissionArgv"] === "auto" || p["permissionArgv"] === "acceptEdits" || p["permissionArgv"] === "bypassPermissions") &&
368
386
  num(p["maxTurns"]) && num(p["repairMaxTurns"]) &&
@@ -384,7 +402,7 @@ export function profileFromJson(json) {
384
402
  return null;
385
403
  }
386
404
  if (p["provider"] === "codex" || p["provider"] === "openrouter") {
387
- if (exactKeys(p, [...PROVIDER_KEYS, "sandboxMode"], ["timeoutKind", "network", "writes", "checkAccess"]) &&
405
+ if (exactKeys(p, [...PROVIDER_KEYS, "sandboxMode"], ["timeoutKind", "network", "writes", "checkAccess", "fencePolicy", "readDeny", "machLookup"]) &&
388
406
  str(p["model"]) &&
389
407
  (p["sandboxMode"] === "workspace-write" || p["sandboxMode"] === "danger-full-access") &&
390
408
  p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
@@ -406,7 +424,7 @@ export function profileFromJson(json) {
406
424
  return null;
407
425
  }
408
426
  if (p["provider"] === "gemini") {
409
- if (exactKeys(p, [...PROVIDER_KEYS, "approvalArgv"], ["timeoutKind", "network", "writes", "checkAccess"]) &&
427
+ if (exactKeys(p, [...PROVIDER_KEYS, "approvalArgv"], ["timeoutKind", "network", "writes", "checkAccess", "fencePolicy", "readDeny", "machLookup"]) &&
410
428
  str(p["model"]) &&
411
429
  (p["approvalArgv"] === "auto_edit" || p["approvalArgv"] === "yolo") &&
412
430
  p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
@@ -428,7 +446,7 @@ export function profileFromJson(json) {
428
446
  return null;
429
447
  }
430
448
  if (typeof p["provider"] === "string" && isAcpProvider(p["provider"])) {
431
- if (exactKeys(p, [...PROVIDER_KEYS, "permission", "idleTimeoutSeconds", "repairIdleTimeoutSeconds"], ["network", "writes", "checkAccess"]) &&
449
+ if (exactKeys(p, [...PROVIDER_KEYS, "permission", "idleTimeoutSeconds", "repairIdleTimeoutSeconds"], ["network", "writes", "checkAccess", "fencePolicy", "readDeny", "machLookup"]) &&
432
450
  str(p["model"]) &&
433
451
  p["permission"] === "full" &&
434
452
  p["maxTurns"] === "unsupported" && p["repairMaxTurns"] === "unsupported" &&
@@ -1278,6 +1296,8 @@ export function describeScope(scope, readiness = NO_READINESS) {
1278
1296
  ...(scope.profile === null || scope.profile === undefined ? [] : [` network ${networkWords(scope.profile.network ?? null).replace(/^Network: /, "")}`]),
1279
1297
  ...(scope.profile === null || scope.profile === undefined ? [] : [` writes ${writesWords(scope.profile.writes)}`]),
1280
1298
  ...(scope.profile?.checkAccess === undefined ? [] : [` checks ${checkAccessWords(scope.profile.checkAccess)}`]),
1299
+ // Its sandbox terms (project-sandbox.ts), part of what the yes agrees to: what it reads, and any widening.
1300
+ ...(scope.profile === null || scope.profile === undefined ? [] : ((terms) => [` reads ${terms.reads}`, ...(terms.mach === null ? [] : [` services ${terms.mach}`])])(sandboxTermsWords(scope.profile))),
1281
1301
  ` reference ${scope.digest}`,
1282
1302
  ` approved ${describeApproval(approval)}`,
1283
1303
  ];
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The scout browser helper's pinned install (scout-browser.ts ensureScoutBrowserHelper): its manifest and lockfile,
3
+ * generated once with `npm install --package-lock-only --registry https://registry.npmjs.org/` for @playwright/mcp
4
+ * 0.0.82. `npm ci` installs exactly this tree and refuses any tarball whose sha512 differs. Changing a version here is
5
+ * a supply-chain change: regenerate both and review the diff.
6
+ */
7
+ export declare const SCOUT_BROWSER_MANIFEST: {
8
+ readonly name: "toolroll-scout-browser";
9
+ readonly private: true;
10
+ readonly dependencies: {
11
+ readonly "@playwright/mcp": "0.0.82";
12
+ };
13
+ };
14
+ export declare const SCOUT_BROWSER_LOCK: {
15
+ readonly name: "toolroll-scout-browser";
16
+ readonly lockfileVersion: 3;
17
+ readonly requires: true;
18
+ readonly packages: {
19
+ readonly "": {
20
+ readonly name: "toolroll-scout-browser";
21
+ readonly dependencies: {
22
+ readonly "@playwright/mcp": "0.0.82";
23
+ };
24
+ };
25
+ readonly "node_modules/@playwright/mcp": {
26
+ readonly version: "0.0.82";
27
+ readonly resolved: "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.82.tgz";
28
+ readonly integrity: "sha512-OCqftfb8H4dnqm/njbTBRk3seUvUPttOlJUxCtEzXGETYOlRH5Qt3bbXIjmZIuWAxD9RF+yg1ASrPeXvm0y5cA==";
29
+ readonly license: "Apache-2.0";
30
+ readonly dependencies: {
31
+ readonly playwright: "1.64.0-alpha-1789764292000";
32
+ readonly "playwright-core": "1.64.0-alpha-1789764292000";
33
+ };
34
+ readonly bin: {
35
+ readonly "playwright-mcp": "cli.js";
36
+ };
37
+ readonly engines: {
38
+ readonly node: ">=18";
39
+ };
40
+ };
41
+ readonly "node_modules/playwright": {
42
+ readonly version: "1.64.0-alpha-1789764292000";
43
+ readonly resolved: "https://registry.npmjs.org/playwright/-/playwright-1.64.0-alpha-1789764292000.tgz";
44
+ readonly integrity: "sha512-3Ngs4ERGdC912uW3srEDtNVey4u1wSaQ/EFcKhxMpz0by0K6nidaJgM087pLpJc1osytiVnL7ack5gvgPArPNw==";
45
+ readonly license: "Apache-2.0";
46
+ readonly dependencies: {
47
+ readonly "playwright-core": "1.64.0-alpha-1789764292000";
48
+ };
49
+ readonly bin: {
50
+ readonly playwright: "cli.js";
51
+ };
52
+ readonly engines: {
53
+ readonly node: ">=20";
54
+ };
55
+ };
56
+ readonly "node_modules/playwright-core": {
57
+ readonly version: "1.64.0-alpha-1789764292000";
58
+ readonly resolved: "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0-alpha-1789764292000.tgz";
59
+ readonly integrity: "sha512-ZgRaybFv4rRy7QMGnYprEGFdNJnvapNqcaER2w96bDQA+40BWIle1el1Yh9KHD3E6XYmieMB+r+UxFTCauTGGQ==";
60
+ readonly license: "Apache-2.0";
61
+ readonly bin: {
62
+ readonly "playwright-core": "cli.js";
63
+ };
64
+ readonly engines: {
65
+ readonly node: ">=20";
66
+ };
67
+ };
68
+ };
69
+ };