toolroll 0.9.70 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.71 — 2026-10-25
4
+
5
+ - **Sandboxed launches can open a terminal of their own on macOS.** A check
6
+ or build that drives a pseudo-terminal (node-pty, `script`, a test that
7
+ runs a shell) now works inside the sandbox. Only terminals the sandboxed
8
+ process made itself open: Toolroll doesn't turn on the sandbox runtime's
9
+ `allowPty`, which also opens your other open terminals, and it now denies
10
+ reading those by name, which the runtime's baseline allowed. Nothing
11
+ sandboxed can type into a terminal, and every sandboxed launch starts in a
12
+ session of its own, so a console started from your terminal doesn't hand
13
+ it that terminal.
14
+ - **Only a build's agent reaches your keychain.** The sandbox runtime lets
15
+ every sandboxed process ask macOS's keychain service, so it could read
16
+ keychain items any program may read (anthropics/sandbox-runtime#755). A
17
+ build's setup and check commands, a project's checks, flows and tool
18
+ servers are now denied it. A build's agent keeps it (Claude Code signs in
19
+ from the keychain), and so does a check whose project gave it
20
+ **sign-ins**, while the instance operator who chose that still is one. The
21
+ sign-ins choice now says it includes Keychain access.
22
+
3
23
  ## 0.9.70 — 2026-10-24
4
24
 
5
25
  - **Every sandbox is now Anthropic's sandbox-runtime.** Builds, their setup
@@ -92,6 +92,10 @@ export type FenceGrants = {
92
92
  homeNames?: string[];
93
93
  /** macOS: the project's sealed mach services beyond the runtime's baseline (its `allowMachLookup`). */
94
94
  machLookup?: string[];
95
+ /** macOS: the keychain's services (securityd), denied to every launch without it (sandbox-runtime.ts
96
+ * SEATBELT_NO_KEYCHAIN). Only fenceFor gives it, to a build's agent; a check with sign-ins is given it at its launch
97
+ * (invoke.ts). Never a tool server's (serverGrantsOf). */
98
+ keychain?: true;
95
99
  /** A `sign-ins` check's exact sign-in files, read again inside the home denies (check-access.ts, signInGrants). */
96
100
  signIns?: SignInGrants;
97
101
  /** Never written, wherever the launch writes (homeWriteDenies): what runs for the operator next or signs them in. */
@@ -11,7 +11,9 @@
11
11
  * Toolroll passes it (srtTermsOf): Toolroll's own state and database denied to read and write; the build's own
12
12
  * checkout writable; other tasks' checkouts read, never written; the project's read-deny list; under policy v1 (an
13
13
  * approval sealed before 0.9.70) the home configuration denied by name; a sign-ins check's exact files read again; the
14
- * run's proxy as its only way out when its network is confined.
14
+ * run's proxy as its only way out when its network is confined. On macOS every launch also gets a pseudo-terminal of its
15
+ * own and no other terminal, and only a build's agent (and a check with sign-ins) reaches the keychain
16
+ * (sandbox-runtime.ts seatbeltRulesFor, static rules).
15
17
  *
16
18
  * Codex (every platform) fences its own agent's commands with its own sandbox: a permission profile with the same
17
19
  * denies (codexFenceArgv). Claude also gets Read/Edit deny rules for its own file tools.
@@ -983,6 +985,7 @@ export function srtTermsOf(fence, grants = NO_GRANTS) {
983
985
  readConfig,
984
986
  writeConfig: { allowOnly: [...new Set(allowOnly)].sort(), denyWithinAllow: [...denyWrite].sort() },
985
987
  allowMachLookup: [...grants.machLookup ?? [], ...grants.signIns?.mach ?? []],
988
+ ...(grants.keychain === true ? { keychain: true } : {}),
986
989
  network: network === undefined || network.mode === "open" ? { kind: "open" } : {
987
990
  kind: "proxy", httpPort: network.proxyPort, ...(network.brokerPorts[0] === undefined ? {} : { socksPort: network.brokerPorts[0] }),
988
991
  ...(network.token === undefined ? {} : { token: network.token }), localServices: network.loopback === "any", unixSockets: [...network.socketRoots],
@@ -1085,6 +1088,8 @@ export function fenceFor(kind, ctx) {
1085
1088
  ...(written === null ? {} : { writes: written.writes, readOnly: [...new Set([...made.readOnly, ...written.readOnly])].sort() }),
1086
1089
  ...(ctx.network === undefined ? {} : { network: ctx.network }),
1087
1090
  ...(ctx.machLookup === undefined || ctx.machLookup.length === 0 ? {} : { machLookup: [...ctx.machLookup] }),
1091
+ // A build's agent signs in from the keychain (Claude Code does); nothing else it launches reaches it.
1092
+ ...(kind === "build" ? { keychain: true } : {}),
1088
1093
  // Under a write allowlist these are out of reach already; where the launch writes anywhere, they are denied by name.
1089
1094
  writeDenies: homeWriteDenies(ctx.home ?? homedir(), ctx.binding, ctx.provider ?? null),
1090
1095
  ...(made.pool === undefined ? {} : { pool: { ...made.pool, ...(ctx.policy === 2 ? { readable: true } : {}),
@@ -1227,7 +1232,7 @@ export function homeWriteDenies(home, binding, provider, platform = process.plat
1227
1232
  * where it always has), and never the build's own checkout or launch folder read back (nothing a build wrote loads in a
1228
1233
  * process that holds a tool's secrets). */
1229
1234
  export function serverGrantsOf(grants) {
1230
- const { network: _agentOnly, writes: _agentWrites, ...rest } = grants;
1235
+ const { network: _agentOnly, writes: _agentWrites, keychain: _agentKeychain, ...rest } = grants;
1231
1236
  return {
1232
1237
  ...rest,
1233
1238
  ...(rest.pool === undefined ? {} : { pool: { ...rest.pool, own: null } }),
@@ -17,11 +17,11 @@ export const widerThan = (a, b) => RANK[a] > RANK[b];
17
17
  export const narrowestAccess = (levels) => levels.reduce((low, one) => RANK[one] < RANK[low] ? one : low, levels.length === 0 ? CHECK_ACCESS_DEFAULT : "unsandboxed");
18
18
  /** The level as a person reads it, in a sentence's middle ("Checks: …"). */
19
19
  export function checkAccessWords(level) {
20
- return level === "sandboxed" ? "sandboxed" : level === "sign-ins" ? "sandboxed, with model and GitHub sign-ins" : "not sandboxed";
20
+ return level === "sandboxed" ? "sandboxed" : level === "sign-ins" ? "sandboxed, with model and GitHub sign-ins and, on a Mac, Keychain access" : "not sandboxed";
21
21
  }
22
22
  /** What each wider level means, said before anyone chooses it. */
23
23
  export const CHECK_ACCESS_WARNINGS = {
24
- "sign-ins": "This project's checks can read your Claude, Codex and GitHub sign-ins, even where the project hides your home folder's files from builds. Its tests could use or send those credentials anywhere its network setting allows. Only choose this for projects you trust.",
24
+ "sign-ins": "This project's checks can read your Claude, Codex and GitHub sign-ins, even where the project hides your home folder's files from builds. On a Mac that includes Keychain access: they can read any keychain item that lets every program read it, not only those sign-ins. Its tests could use or send those credentials anywhere its network setting allows. Only choose this for projects you trust.",
25
25
  unsandboxed: "This project's checks get your full access to this machine and to Toolroll itself, including its database and every sign-in. Only for a project whose checks must create sandboxes themselves, such as Toolroll's own.",
26
26
  };
27
27
  /** The services whose sign-ins a `sign-ins` check reads: Claude's and Codex's own hosts (egress.ts), and GitHub's. */
package/dist/invoke.d.ts CHANGED
@@ -135,6 +135,13 @@ export declare function fencedCheckoutCommands(store: Store, repo: string, check
135
135
  * under: open, or its allowlist through a proxy of its own (on Linux, relayed in by the sandbox runtime). Nothing about it is
136
136
  * started unless all of it is.
137
137
  */
138
+ /**
139
+ * Whether a check with sign-ins also reaches the keychain (macOS securityd: any item every program may read, not only
140
+ * the sign-ins). Only while the project's sign-ins level stands as a person set it: widening to sign-ins takes an
141
+ * instance operator's password typed now (operate.ts project check-access, Settings → Project → Check access), and the
142
+ * one who set it must still be an approver for the project and an instance operator now. Anything else: no keychain.
143
+ */
144
+ export declare function signInsKeychain(store: Store, repo: string | null): boolean;
138
145
  export declare function signInsLaunch(store: Store, own: string, temp: string, checkout: string, policy: NetworkProfile, gitPaths: readonly string[], platform?: NodeJS.Platform): Promise<{
139
146
  grants: SignInGrants;
140
147
  network: NetworkFence;
package/dist/invoke.js CHANGED
@@ -1017,7 +1017,9 @@ export function fencedCommands(store, runId, writes, inner, available = () => ma
1017
1017
  ...options,
1018
1018
  fence: fenced.fence,
1019
1019
  // Each sign-in file looked at again right before the launch: one no longer itself isn't granted.
1020
- fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network },
1020
+ // With sign-ins, the keychain too (Claude Code keeps its macOS sign-in there), only while a person still set it.
1021
+ fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network,
1022
+ ...(signInsKeychain(store, checkout === null ? null : store.getWorktree(checkout)?.repo ?? null) ? { keychain: true } : {}) },
1021
1023
  confinement: "required",
1022
1024
  env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)), ...(signIns?.env ?? {}) },
1023
1025
  ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
@@ -1086,7 +1088,9 @@ export function fencedCheckoutCommands(store, repo, checkout, inner, available =
1086
1088
  ...options,
1087
1089
  fence: launch.fence,
1088
1090
  // Each sign-in file looked at again right before the launch: one no longer itself isn't granted.
1089
- fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network },
1091
+ // With sign-ins, the keychain too (Claude Code keeps its macOS sign-in there), only while a person still set it.
1092
+ fenceGrants: signIns === null ? grants : { ...grants, signIns: { ...signIns.grants, read: signIns.grants.read.filter(path => signInIsItself(realpathSync(homedir()), path)) }, network: signIns.network,
1093
+ ...(signInsKeychain(store, repo) ? { keychain: true } : {}) },
1090
1094
  confinement: launch.confinement,
1091
1095
  env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)), ...(signIns?.env ?? {}) },
1092
1096
  ...(signIns === null ? {} : { omitEnv: [...(options?.omitEnv ?? []), ...SIGN_INS_OMITTED_ENV] }),
@@ -1173,6 +1177,19 @@ const SIGN_INS_OMITTED_ENV = ["CLAUDE_CONFIG_DIR"];
1173
1177
  * under: open, or its allowlist through a proxy of its own (on Linux, relayed in by the sandbox runtime). Nothing about it is
1174
1178
  * started unless all of it is.
1175
1179
  */
1180
+ /**
1181
+ * Whether a check with sign-ins also reaches the keychain (macOS securityd: any item every program may read, not only
1182
+ * the sign-ins). Only while the project's sign-ins level stands as a person set it: widening to sign-ins takes an
1183
+ * instance operator's password typed now (operate.ts project check-access, Settings → Project → Check access), and the
1184
+ * one who set it must still be an approver for the project and an instance operator now. Anything else: no keychain.
1185
+ */
1186
+ export function signInsKeychain(store, repo) {
1187
+ if (repo === null || store.projectCheckAccess(repo) !== "sign-ins")
1188
+ return false;
1189
+ const change = store.projectCheckAccessChange(repo);
1190
+ const account = change === null ? null : store.accountOf(change.by);
1191
+ return account !== null && account.revokedAt === null && account.role === "approver" && store.accountCanAccess(change.by, repo) && store.isInstanceOperator(change.by);
1192
+ }
1176
1193
  export async function signInsLaunch(store, own, temp, checkout, policy, gitPaths, platform = process.platform) {
1177
1194
  if (!signInsAvailable(platform))
1178
1195
  return { problem: "Sign-ins can't be shared with checks here, so nothing ran." };
@@ -96,7 +96,7 @@ function checkAccessHtml(view) {
96
96
  const setting = unavailable !== null && saved === "sign-ins" ? "sandboxed" : saved;
97
97
  const network = view.network?.setting ?? null;
98
98
  const names = { sandboxed: "Sandboxed", "sign-ins": "Sandboxed, with model and GitHub sign-ins", unsandboxed: "Not sandboxed" };
99
- 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." };
100
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).
101
101
  const missing = setting === "sign-ins" && network !== null ? signInHostsMissing(network) : [];
102
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>`;
@@ -15,6 +15,9 @@ export type SrtTerms = {
15
15
  readConfig: FsReadRestrictionConfig;
16
16
  writeConfig: FsWriteRestrictionConfig;
17
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;
18
21
  network: {
19
22
  kind: "open";
20
23
  } | {
@@ -38,8 +41,51 @@ export declare function shellQuote(words: readonly string[]): string;
38
41
  * the launch's own temp folder when it has one, so it keeps it; otherwise /tmp/claude, made if missing.
39
42
  */
40
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;
41
79
  /** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
42
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[];
43
89
  /** The Linux helper (sandbox-launch.ts) and the environment variable its launch is handed in. */
44
90
  export declare const SANDBOX_LAUNCH_SPEC = "TOOLROLL_SANDBOX_LAUNCH";
45
91
  export declare const sandboxLaunchHelper: () => string;
@@ -52,7 +98,8 @@ export type SandboxLaunchSpec = {
52
98
  temp?: string;
53
99
  };
54
100
  /**
55
- * `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command>`; on Linux the helper,
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,
56
103
  * handed the launch in its environment (never its arguments, which other users can list).
57
104
  */
58
105
  export declare function srtSpawn(file: string, args: readonly string[], terms: SrtTerms, cwd?: string, temp?: string, platform?: NodeJS.Platform): {
@@ -2,8 +2,10 @@
2
2
  * The sandbox engine: Anthropic's sandbox-runtime (`@anthropic-ai/sandbox-runtime`, pinned in package.json). Every
3
3
  * macOS Seatbelt profile and every Linux bubblewrap + seccomp invocation a fenced launch runs under is generated by
4
4
  * its own code, per launch, from a small static config (read denies with exact re-allows, write roots with denies
5
- * inside them, mach services, the run's proxy). Toolroll adds only what that config says (agent-fence.ts srtTermsOf);
6
- * the rules themselves, their baseline and their limits are the sandbox runtime's.
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.
7
9
  *
8
10
  * macOS: its profile builder runs in this process (it is synchronous), with this process's working folder set to the
9
11
  * launch's for the call, because the runtime anchors its mandatory write denies (.git/hooks, .vscode, shell profiles)
@@ -90,6 +92,133 @@ export function withRuntimeTemp(temp, make) {
90
92
  process.env["CLAUDE_TMPDIR"] = before.old;
91
93
  }
92
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
+ }
93
222
  /** The runtime's macOS invocation for `file args` run from `cwd`: a command line for /bin/sh. */
94
223
  export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
95
224
  const network = terms.network;
@@ -110,13 +239,24 @@ export function srtMacosCommand(file, args, terms, cwd = process.cwd(), temp) {
110
239
  // The runtime anchors its mandatory write denies at this process's working folder: the launch's, for this call.
111
240
  const before = process.cwd();
112
241
  process.chdir(cwd);
242
+ let command;
113
243
  try {
114
- return withRuntimeTemp(temp, () => wrapCommandWithSandboxMacOS(params));
244
+ command = withRuntimeTemp(temp, () => wrapCommandWithSandboxMacOS(params));
115
245
  }
116
246
  finally {
117
247
  process.chdir(before);
118
248
  }
249
+ return withSeatbeltRules(command, seatbeltRulesFor(terms), params.command);
119
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";'];
120
260
  /** The Linux helper (sandbox-launch.ts) and the environment variable its launch is handed in. */
121
261
  export const SANDBOX_LAUNCH_SPEC = "TOOLROLL_SANDBOX_LAUNCH";
122
262
  export const sandboxLaunchHelper = () => {
@@ -125,7 +265,8 @@ export const sandboxLaunchHelper = () => {
125
265
  return existsSync(beside) ? beside : fileURLToPath(new URL("../dist/sandbox-launch.js", import.meta.url));
126
266
  };
127
267
  /**
128
- * `file args` inside the runtime's sandbox, as a spawn: on macOS `/bin/sh -c <its command>`; on Linux the helper,
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,
129
270
  * handed the launch in its environment (never its arguments, which other users can list).
130
271
  */
131
272
  export function srtSpawn(file, args, terms, cwd = process.cwd(), temp, platform = process.platform) {
@@ -133,7 +274,7 @@ export function srtSpawn(file, args, terms, cwd = process.cwd(), temp, platform
133
274
  const command = srtMacosCommand(file, args, terms, cwd, temp);
134
275
  if (!command.includes(SANDBOX_EXEC))
135
276
  throw new Error("the sandbox runtime returned the command unsandboxed");
136
- return { file: "/bin/sh", args: ["-c", command], env: {} };
277
+ return { file: "/usr/bin/perl", args: [...DETACH, "/bin/sh", "-c", command], env: {} };
137
278
  }
138
279
  const spec = { file, args: [...args], cwd, terms, ...(temp === undefined ? {} : { temp }) };
139
280
  return { file: process.execPath, args: [sandboxLaunchHelper()], env: { [SANDBOX_LAUNCH_SPEC]: JSON.stringify(spec) } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.70",
3
+ "version": "0.9.71",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"