omp-conductor 0.3.24 → 0.4.0

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.
@@ -0,0 +1,2029 @@
1
+ /**
2
+ * The credential boundary between the daemon and model-executed code (#125).
3
+ *
4
+ * The fact this file exists to fix: until it did, a worker session ran in the
5
+ * daemon's own process, as the daemon's own user, with the daemon's `$HOME` and
6
+ * `process.env`. The daemon authenticates by shelling out to the operator's
7
+ * logged-in `gh`, so a worker's `bash` reached the same credential by running
8
+ * the same binary. Scrubbing `GH_TOKEN` does not fix that and must never be
9
+ * described as if it did: same-uid code defeats an environment variable in one
10
+ * line (`GH_CONFIG_DIR=$HOME/.config/gh gh pr merge`), and the keychain,
11
+ * `~/.ssh` and `~/.git-credentials` need no environment at all.
12
+ *
13
+ * So this module has exactly two jobs, and they are not the same kind of thing:
14
+ *
15
+ * 1. **{@link credentialedEnv} — the one construction site of credential
16
+ * material in the daemon.** Every privileged spawn (`gh`, the mirror's
17
+ * clone/fetch, {@link pushRunBranch}, {@link openRunPr}) goes through it, so
18
+ * "which processes can reach the credential" is a list one grep long.
19
+ * 2. **{@link probeHost} and {@link launcherArgv} — the OS principal.** This is
20
+ * the layer that makes the claim true. {@link sessionEnv} sits beside it as
21
+ * *convenience and accident-prevention only*; see its own comment.
22
+ *
23
+ * Nothing here may describe env scrubbing, or the degraded `group-mode`
24
+ * mechanism, as containing a determined bash escape. Only `uid-pool` on Linux
25
+ * and `sandbox-exec` on macOS make that claim, and `status` says which is live.
26
+ */
27
+
28
+ import {
29
+ chmodSync,
30
+ chownSync,
31
+ existsSync,
32
+ lstatSync,
33
+ mkdirSync,
34
+ readFileSync,
35
+ readdirSync,
36
+ rmSync,
37
+ writeFileSync,
38
+ type Stats,
39
+ } from "node:fs";
40
+ import { dirname, join, resolve } from "node:path";
41
+
42
+ import {
43
+ CONDUCTOR_GROUPS,
44
+ type CredentialIsolation,
45
+ type IsolationMechanism,
46
+ type ProjectConfig,
47
+ type RepoTarget,
48
+ type SessionRole,
49
+ } from "./types.ts";
50
+
51
+ /**
52
+ * Environment variables that carry, or lead to, a write credential. Removed
53
+ * from every session child.
54
+ *
55
+ * The first four are the set #125 names. `NPM_TOKEN`/`NODE_AUTH_TOKEN` are here
56
+ * because publish credentials are inside the same blast radius — `npm whoami`
57
+ * failing is one of the issue's acceptance probes — and `GH_HOST` because it
58
+ * redirects `gh` at an enterprise host whose credential may be configured
59
+ * separately from github.com's.
60
+ */
61
+ export const CREDENTIAL_ENV_KEYS = [
62
+ "GH_TOKEN",
63
+ "GITHUB_TOKEN",
64
+ "GH_ENTERPRISE_TOKEN",
65
+ "GITHUB_ENTERPRISE_TOKEN",
66
+ "SSH_AUTH_SOCK",
67
+ "GH_HOST",
68
+ "NPM_TOKEN",
69
+ "NODE_AUTH_TOKEN",
70
+ ] as const;
71
+
72
+ /**
73
+ * A binary that always fails, for `GIT_ASKPASS`/`SSH_ASKPASS` and git's own
74
+ * `core.askPass`.
75
+ *
76
+ * #125 names `/bin/false`, which is right on Linux and **does not exist on
77
+ * macOS** — there it is `/usr/bin/false`. The difference is not academic: a
78
+ * missing askpass makes git fail with `cannot exec '/bin/false'`, which happens
79
+ * to be the outcome we want but reads in a log like a broken conductor rather
80
+ * than a refused credential. Resolved once so the failure says what it means.
81
+ */
82
+ const ALWAYS_FAILS = existsSync("/bin/false") ? "/bin/false" : "/usr/bin/false";
83
+
84
+ /**
85
+ * The `.gitconfig` a session gets. No credential helper, and `askpass` wired to
86
+ * a binary that always fails, so an https push cannot silently pick up a helper
87
+ * the operator configured globally. Fenced and rewritten on every dispatch, the
88
+ * same way `info/exclude` is, so an older release's file heals itself.
89
+ */
90
+ const SESSION_GITCONFIG = [
91
+ "# Written by omp-conductor for one session (#125). Rewritten on every dispatch.",
92
+ "# Deliberately empty of credential helpers: a session must not be able to",
93
+ "# reach a credential through git's own configuration. This is the",
94
+ "# accident-prevention layer — the OS principal is what makes it true.",
95
+ "[credential]",
96
+ "\thelper =",
97
+ "[core]",
98
+ `\taskPass = ${ALWAYS_FAILS}`,
99
+ // The run repository borrows its objects from a mirror the DAEMON owns, and
100
+ // git refuses to touch a repository owned by another uid ("detected dubious
101
+ // ownership") — which under `uid-pool` is every object read through
102
+ // alternates. Without this the checkout looks corrupt on the first command,
103
+ // and the operator is told to run a `git config --global` the session is
104
+ // deliberately unable to make stick. It is a trust statement about ownership,
105
+ // not a permission grant: the mirror stays 0750 and unwritable.
106
+ "[safe]",
107
+ "\tdirectory = *",
108
+ "",
109
+ ].join("\n");
110
+
111
+ /**
112
+ * One process run, captured. Injected everywhere in this module so the probe,
113
+ * the launcher smoke test and the daemon-side push are all testable without a
114
+ * privileged host.
115
+ */
116
+ export type Exec = (
117
+ argv: readonly string[],
118
+ opts?: { cwd?: string; env?: Record<string, string>; stdin?: string },
119
+ ) => Promise<{ code: number; stdout: string; stderr: string }>;
120
+
121
+ /** The real one. Never inherits stdin: an unattended daemon must not block on a prompt. */
122
+ export const spawnCaptured: Exec = async (argv, opts = {}) => {
123
+ const [command, ...rest] = argv;
124
+ if (command === undefined) return { code: 127, stdout: "", stderr: "empty argv" };
125
+ const proc = Bun.spawn([command, ...rest], {
126
+ ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }),
127
+ ...(opts.env === undefined ? {} : { env: opts.env }),
128
+ stdin: new Blob([opts.stdin ?? ""]),
129
+ stdout: "pipe",
130
+ stderr: "pipe",
131
+ });
132
+ const [stdout, stderr, code] = await Promise.all([
133
+ new Response(proc.stdout).text(),
134
+ new Response(proc.stderr).text(),
135
+ proc.exited,
136
+ ]);
137
+ return { code, stdout, stderr };
138
+ };
139
+
140
+ // --------------------------------------------------------------- credentials
141
+
142
+ /**
143
+ * The daemon's own environment, credentials intact. **The only place this
144
+ * package constructs credential material.**
145
+ *
146
+ * There is nothing clever in the body, and that is deliberate: the value is
147
+ * that it is one named function, so "what can reach the operator's `gh`
148
+ * credential" is answered by its call sites rather than by auditing every
149
+ * `Bun.spawn` in the tree. A privileged spawn that does not come through here
150
+ * is a bug, whatever it happens to do.
151
+ *
152
+ * `GIT_TERMINAL_PROMPT=0` rides along because every caller is unattended: a
153
+ * credential prompt nobody can answer is a hang, not a failure.
154
+ */
155
+ export function credentialedEnv(
156
+ extra: Readonly<Record<string, string>> = {},
157
+ base: Readonly<Record<string, string | undefined>> = process.env,
158
+ ): Record<string, string> {
159
+ const env: Record<string, string> = {};
160
+ for (const [key, value] of Object.entries(base)) {
161
+ if (value !== undefined) env[key] = value;
162
+ }
163
+ env["GIT_TERMINAL_PROMPT"] = "0";
164
+ return { ...env, ...extra };
165
+ }
166
+
167
+ /** Where one session's private config tree lives. All of it daemon-created. */
168
+ export interface SessionEnvRoot {
169
+ /** `$HOME` for the child — the agent principal's config root, not the operator's. */
170
+ home: string;
171
+ /** `GH_CONFIG_DIR`: exists, is empty, and is not the operator's. */
172
+ ghConfigDir: string;
173
+ /** `GIT_CONFIG_GLOBAL`: conductor-owned, no credential helper. */
174
+ gitConfigGlobal: string;
175
+ /** `NPM_CONFIG_USERCONFIG`: an empty file, so `~/.npmrc` is not consulted. */
176
+ npmrc: string;
177
+ /** `TMPDIR`: inside the boundary, so a sandbox profile can grant it by name. */
178
+ tmp: string;
179
+ }
180
+
181
+ /**
182
+ * Materialise a session's private config tree under `root`.
183
+ *
184
+ * `$HOME` is redirected rather than merely scrubbed because the harness
185
+ * discovers its user-level configuration — including `mcp.json` — from
186
+ * `os.homedir()`, which reads `$HOME`. Leaving it pointed at the operator's
187
+ * home is what put a GitHub MCP server carrying its own PAT inside every
188
+ * session (#125 item 5). The redirect is what makes "the agent principal's
189
+ * config root" a real directory an operator can populate deliberately.
190
+ */
191
+ export function prepareSessionEnvRoot(root: string): SessionEnvRoot {
192
+ const paths: SessionEnvRoot = {
193
+ home: join(root, "home"),
194
+ ghConfigDir: join(root, "gh"),
195
+ gitConfigGlobal: join(root, "gitconfig"),
196
+ npmrc: join(root, "npmrc"),
197
+ tmp: join(root, "tmp"),
198
+ };
199
+ // 0711 on the root, 0700 on the leaves: a slot principal reaches its own
200
+ // subtree by traversal and cannot list what else the daemon put here.
201
+ mkdirSync(root, { recursive: true, mode: 0o711 });
202
+ for (const dir of [paths.home, paths.ghConfigDir, paths.tmp]) {
203
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
204
+ }
205
+ writeFileSync(paths.gitConfigGlobal, SESSION_GITCONFIG, { mode: 0o644 });
206
+ if (!existsSync(paths.npmrc)) writeFileSync(paths.npmrc, "", { mode: 0o644 });
207
+ return paths;
208
+ }
209
+
210
+ /**
211
+ * The environment one session child runs with.
212
+ *
213
+ * **This layer is convenience and accident-prevention, not the boundary.** It
214
+ * stops a well-behaved tool from picking up a credential it was never meant to
215
+ * see, and it makes the common accidents — `git push` finding a helper, `gh`
216
+ * finding a config — fail immediately and legibly. It stops nothing that is
217
+ * trying. Model-executed code running as the same uid re-points every one of
218
+ * these variables in a single line, and needs none of them to read the
219
+ * keychain or `~/.ssh`. What makes the claim in #125 true is the OS principal
220
+ * selected by {@link probeHost} — and when that resolves to `none`, this
221
+ * function is all there is and `status` says the fleet is unprotected.
222
+ */
223
+ export function sessionEnv(
224
+ paths: SessionEnvRoot,
225
+ opts: { readToken?: string } = {},
226
+ base: Readonly<Record<string, string | undefined>> = process.env,
227
+ ): Record<string, string> {
228
+ const env: Record<string, string> = {};
229
+ for (const [key, value] of Object.entries(base)) {
230
+ if (value !== undefined) env[key] = value;
231
+ }
232
+ for (const key of CREDENTIAL_ENV_KEYS) delete env[key];
233
+
234
+ env["HOME"] = paths.home;
235
+ env["GH_CONFIG_DIR"] = paths.ghConfigDir;
236
+ env["GIT_CONFIG_GLOBAL"] = paths.gitConfigGlobal;
237
+ // Without this a session inherits the *system* gitconfig's helpers, which on
238
+ // a developer host is where `osxkeychain` usually lives.
239
+ env["GIT_CONFIG_SYSTEM"] = "/dev/null";
240
+ env["GIT_CONFIG_NOSYSTEM"] = "1";
241
+ env["GIT_TERMINAL_PROMPT"] = "0";
242
+ env["GIT_ASKPASS"] = ALWAYS_FAILS;
243
+ env["SSH_ASKPASS"] = ALWAYS_FAILS;
244
+ env["NPM_CONFIG_USERCONFIG"] = paths.npmrc;
245
+ env["TMPDIR"] = paths.tmp;
246
+
247
+ // The one credential a session may be given, and only when an operator
248
+ // configured it. Read-scoped by construction on GitHub's side — this package
249
+ // cannot verify the scope, so the README says plainly that a write-scoped
250
+ // value here reopens the hole the rest of this file closes.
251
+ if (opts.readToken !== undefined && opts.readToken !== "") {
252
+ env["GH_TOKEN"] = opts.readToken;
253
+ env["GITHUB_TOKEN"] = opts.readToken;
254
+ }
255
+ return env;
256
+ }
257
+
258
+ // ------------------------------------------------------------- the principal
259
+
260
+ /**
261
+ * One run slot's OS account. `gid`/`group` are the slot's *own* primary group,
262
+ * never a shared one: the shared group a slot gets back is
263
+ * {@link CONDUCTOR_GROUPS.runs}, and it is granted through the launcher's
264
+ * supplementary list rather than by making it anyone's primary.
265
+ */
266
+ export interface SlotPrincipal {
267
+ /** Pool index, or `"orch"` for the orchestrator's own distinct account. */
268
+ slot: string;
269
+ user: string;
270
+ uid: number;
271
+ gid: number;
272
+ group: string;
273
+ }
274
+
275
+ /** Everything one session child needs to be launched behind the boundary. */
276
+ export interface SessionBoundary {
277
+ mechanism: IsolationMechanism;
278
+ /** argv prefix that drops privilege. Empty for `group-mode` and `none`. */
279
+ launcher: string[];
280
+ /** Environment overrides for the child (see {@link sessionEnv}). */
281
+ env: Record<string, string>;
282
+ /** Present only when the mechanism gives this run a distinct uid. */
283
+ principal?: SlotPrincipal;
284
+ }
285
+
286
+ /**
287
+ * The privilege-dropping launcher, composed exactly once.
288
+ *
289
+ * **Never `spawn({ uid, gid })`.** Granting the daemon ambient
290
+ * `CAP_SETUID`/`CAP_SETGID` and calling a raw spawn looks identical from the
291
+ * outside and voids the entire boundary: ambient capabilities survive `execve`
292
+ * for ordinary binaries, so the child holds `CAP_SETUID` itself and can
293
+ * `setuid()` straight back — to the daemon, or to a sibling run. `setpriv` is
294
+ * preferred over a hand-rolled helper for the same reason one uses `sudo`
295
+ * instead of a setuid shell script: it is audited, and it does the five steps
296
+ * in the order that makes them stick (groups, gid, uid, empty every capability
297
+ * set, `PR_SET_NO_NEW_PRIVS`, then exec).
298
+ *
299
+ * Two flag traps, both of which fail *before* exec and so read like a broken
300
+ * conductor rather than a permissions problem:
301
+ *
302
+ * - `--clear-groups`, `--groups`, `--keep-groups` and `--init-groups` are
303
+ * mutually exclusive. `--clear-groups --groups conductor-runs` exits non-zero
304
+ * before running anything. `--groups` alone already *replaces* the inherited
305
+ * list, which is the wanted behaviour, so it is used alone.
306
+ * - The one group granted back is the read-only mirror group. Omit it and the
307
+ * child cannot read the shared mirror, so git alternates fail with a
308
+ * permission error that reads like a corrupt object store.
309
+ *
310
+ * `--bounding-set=-all` performs `PR_CAPBSET_DROP`, which needs `CAP_SETPCAP`
311
+ * in the *caller's* permitted set. When the host did not grant it the flag is
312
+ * omitted rather than attempted: with every other set empty, an unprivileged
313
+ * uid and `NoNewPrivs: 1`, the child cannot acquire anything in the bounding
314
+ * set, so the leftover `CapBnd` is inert — and reported by `status` as a named
315
+ * residual instead of quietly ignored.
316
+ */
317
+ export function setprivArgv(
318
+ principal: SlotPrincipal,
319
+ opts: { boundingSet: boolean; sharedGroup?: string | false },
320
+ command: readonly string[],
321
+ ): string[] {
322
+ // `false` means "no supplementary group at all", which is the orchestrator's
323
+ // case: it never reads the shared mirror, so granting it `conductor-runs`
324
+ // would hand it the one residual the design says it does not get. Expressed
325
+ // as `--clear-groups`, which is valid alone and is mutually exclusive with
326
+ // `--groups` — passing both aborts before exec.
327
+ const groups = opts.sharedGroup === false ? "--clear-groups" : `--groups=${opts.sharedGroup ?? CONDUCTOR_GROUPS.runs}`;
328
+ return [
329
+ "setpriv",
330
+ `--reuid=${principal.user}`,
331
+ `--regid=${principal.group}`,
332
+ groups,
333
+ "--inh-caps=-all",
334
+ "--ambient-caps=-all",
335
+ ...(opts.boundingSet ? ["--bounding-set=-all"] : []),
336
+ "--no-new-privs",
337
+ "--",
338
+ ...command,
339
+ ];
340
+ }
341
+
342
+
343
+ /**
344
+ * The exact, idempotent root commands that build the `uid-pool` topology.
345
+ *
346
+ * Shipped rather than documented, and generated rather than duplicated: the
347
+ * probe, the dispatch-time refusals and this script have to agree about account
348
+ * names, group names, modes and the shared root, and three prose copies of that
349
+ * would drift the first time one of them changed. `omp-conductor boundary-setup`
350
+ * prints it; CI runs the same output. Nothing here runs automatically — it
351
+ * creates system accounts, so it is the operator's call and their `sudo`.
352
+ *
353
+ * Printed rather than executed for the same reason `graph-setup` prints: an
354
+ * operator gets to read what will touch their box before it does.
355
+ */
356
+ /**
357
+ * Credential paths inside the daemon's home, relative.
358
+ *
359
+ * One list, two consumers: the generated hardening chmods them, and
360
+ * `credentialDenyFiles()` builds the empirical recheck from the same names. A
361
+ * path hardened but not probed is a credential nobody verified was out of
362
+ * reach — which is exactly how `.gitconfig` (and its `url.*.insteadOf` inline
363
+ * tokens) was chmod-ed while dispatch still reported clean.
364
+ */
365
+ export const CREDENTIAL_DIRS = [".ssh", ".config/gh", ".gnupg", ".aws"] as const;
366
+ export const CREDENTIAL_FILES = [
367
+ ".npmrc",
368
+ ".git-credentials",
369
+ ".netrc",
370
+ ".gitconfig",
371
+ ".config/git/config",
372
+ ] as const;
373
+
374
+ export function boundarySetupScript(opts: { slots: number; sharedRoot: string; daemonUser: string }): string {
375
+ const slots = Array.from({ length: opts.slots }, (_, i) => `${DEFAULT_ACCOUNT_PREFIX}${String(i)}`);
376
+ const accounts = [...slots, `${DEFAULT_ACCOUNT_PREFIX}orch`];
377
+ const q = (s: string): string => JSON.stringify(s);
378
+ return [
379
+ "#!/usr/bin/env bash",
380
+ "# Generated by `omp-conductor boundary-setup`. Idempotent; run as root.",
381
+ "set -euo pipefail",
382
+ "",
383
+ `DAEMON_USER=${q(opts.daemonUser)}`,
384
+ `SHARED_ROOT=${q(opts.sharedRoot)}`,
385
+ "",
386
+ "# Two groups: one the daemon alone holds (so it can fetch, salvage and",
387
+ "# reclaim every run repo), one every slot holds (read-only mirror access).",
388
+ `getent group ${CONDUCTOR_GROUPS.daemon} >/dev/null || groupadd --system ${CONDUCTOR_GROUPS.daemon}`,
389
+ `getent group ${CONDUCTOR_GROUPS.runs} >/dev/null || groupadd --system ${CONDUCTOR_GROUPS.runs}`,
390
+ "",
391
+ ...accounts.flatMap((user) => [
392
+ `id ${user} >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin --user-group ${user}`,
393
+ `usermod -aG ${CONDUCTOR_GROUPS.runs} ${user}`,
394
+ `# A slot in the daemon group would reach every other run's repo.`,
395
+ `gpasswd -d ${user} ${CONDUCTOR_GROUPS.daemon} >/dev/null 2>&1 || true`,
396
+ ]),
397
+ "",
398
+ `usermod -aG ${CONDUCTOR_GROUPS.daemon} "$DAEMON_USER"`,
399
+ `usermod -aG ${CONDUCTOR_GROUPS.runs} "$DAEMON_USER"`,
400
+ "",
401
+ "# Searchable, unlistable, unwritable — a slot reaches its own tree and",
402
+ "# cannot enumerate its siblings.",
403
+ 'mkdir -p "$SHARED_ROOT"',
404
+ `chown "$DAEMON_USER":${CONDUCTOR_GROUPS.daemon} "$SHARED_ROOT"`,
405
+ 'chmod 0711 "$SHARED_ROOT"',
406
+ "",
407
+ "# The daemon's home must be BOTH searchable and closed: the runtime and the",
408
+ "# installed package live in it, so 0700 kills every worker before it starts,",
409
+ "# while the credential leaves are what the boundary actually rests on.",
410
+ 'DAEMON_HOME="$(getent passwd "$DAEMON_USER" | cut -d: -f6)"',
411
+ 'if [ -n "$DAEMON_HOME" ] && [ -d "$DAEMON_HOME" ]; then',
412
+ ' chmod 0711 "$DAEMON_HOME"',
413
+ ` for d in ${CREDENTIAL_DIRS.join(" ")}; do [ -d "$DAEMON_HOME/$d" ] && chmod 0700 "$DAEMON_HOME/$d"; done`,
414
+ ` for f in ${CREDENTIAL_FILES.join(" ")}; do [ -f "$DAEMON_HOME/$f" ] && chmod 0600 "$DAEMON_HOME/$f"; done`,
415
+ "fi",
416
+ "",
417
+ 'echo "boundary provisioned. Supplementary groups apply to NEW processes:"',
418
+ 'echo " systemctl restart omp-conductor.service"',
419
+ "",
420
+ ].join("\n");
421
+ }
422
+
423
+ /**
424
+ * Ask the slot principal itself whether it can read any credential path, and
425
+ * refuse the boundary if it can.
426
+ *
427
+ * This is the difference between the claim and the wish. On macOS the
428
+ * `sandbox-exec` profile carries `denyReadRoots`/`denyReadFiles` and enforces
429
+ * them; on Linux `uid-pool` carries no such list — separation is DAC, and DAC
430
+ * is the *operator's* file modes, which nothing in this package chose. A home
431
+ * directory is commonly `0755`, `~/.npmrc` and `~/.gitconfig` commonly `0644`,
432
+ * and an inline token in either is then readable by every slot uid on the box.
433
+ * The boundary would report `uid-pool` and be wide open.
434
+ *
435
+ * So the check is empirical and runs as the principal: `test -r` through the
436
+ * real launcher, so ACLs, setgid and anything else the kernel honours all
437
+ * count. Anything readable is named and the mechanism refuses rather than
438
+ * resolving down quietly — a credential the model can read is the whole thing
439
+ * #125 exists to prevent.
440
+ */
441
+ export async function credentialReadRefusal(
442
+ exec: Exec,
443
+ principal: SlotPrincipal,
444
+ opts: { boundingSet: boolean; sharedGroup?: string | false },
445
+ paths: readonly string[],
446
+ ): Promise<string | undefined> {
447
+ if (paths.length === 0) return undefined;
448
+ const q = (s: string): string => JSON.stringify(s);
449
+ // A directory leaks on **search**, not just read. With `$HOME` at 0711 a
450
+ // drifted `.ssh` at 0711 defeats `[ -r dir ]` and `ls` while
451
+ // `cat ~/.ssh/id_ed25519` still works, because reaching a known leaf needs
452
+ // only `+x` on the way down. So directories are tested for either bit, and
453
+ // the well-known leaves are additionally opened by exact path — which is what
454
+ // an attacker does, and what a listing-based check never notices.
455
+ const LEAVES: Record<string, readonly string[]> = {
456
+ ".ssh": ["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"],
457
+ "gh": ["hosts.yml"],
458
+ "git": ["config"],
459
+ };
460
+ const lines: string[] = [];
461
+ for (const p of paths) {
462
+ lines.push(`if [ -d ${q(p)} ]; then [ -r ${q(p)} ] || [ -x ${q(p)} ]; else [ -r ${q(p)} ]; fi && printf '%s\\n' ${q(p)}`);
463
+ const base = p.split("/").pop() ?? "";
464
+ for (const leaf of LEAVES[base] ?? []) {
465
+ const full = `${p}/${leaf}`;
466
+ lines.push(`[ -r ${q(full)} ] && printf '%s\\n' ${q(full)}`);
467
+ }
468
+ }
469
+ // `CHECKED` is the proof the launcher actually ran. Without it a `setpriv`
470
+ // the host rejects exits nonzero with empty stdout, which is indistinguishable
471
+ // from "nothing was readable" — the one wrong answer this function must never
472
+ // give. Absence is a refusal, not a pass.
473
+ const script = `${lines.join("; ")}; printf 'CHECKED\\n'`;
474
+ const out = await exec(setprivArgv(principal, opts, ["/bin/sh", "-c", script]));
475
+ const seen = out.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
476
+ if (!seen.includes("CHECKED")) {
477
+ return (
478
+ `the credential readability check could not be run as ${principal.user} (launcher exited ` +
479
+ `${String(out.code)}: ${out.stderr.trim() || "no output"}). Refusing rather than assuming the ` +
480
+ `credentials are out of reach.`
481
+ );
482
+ }
483
+ const readable = seen.filter((l) => l !== "CHECKED");
484
+ if (readable.length === 0) return undefined;
485
+ return (
486
+ `${principal.user} can reach ${String(readable.length)} credential path(s) the boundary claims to ` +
487
+ `deny: ${readable.join(", ")}. On Linux the per-run principal separates by file permissions only, so a ` +
488
+ `world-readable file — or a merely *searchable* credential directory — defeats it entirely. Tighten ` +
489
+ `those paths (0600 files, 0700 credential directories) and restart.`
490
+ );
491
+ }
492
+
493
+ /**
494
+ * A macOS sandbox profile whose only writable paths are this run's own tree.
495
+ *
496
+ * Shaped as allow-default plus targeted denials rather than deny-default plus
497
+ * allowances, and that is not laziness: a deny-default profile has to enumerate
498
+ * every dyld cache, sysctl and mach service the harness and its toolchain
499
+ * touch, and the failure mode when it misses one is `SIGABRT` at exec with no
500
+ * diagnostic — measured on this host while developing #125. The write rule is
501
+ * still a whitelist (`deny file-write*` then allow the run's tree), which is
502
+ * the property the issue asks for; the read denials are what make the
503
+ * credential paths unreachable.
504
+ *
505
+ * Paths must be *resolved* — `/tmp` is a symlink to `/private/tmp` and a rule
506
+ * naming the symlink matches nothing, which silently produces a profile that
507
+ * denies the writes it meant to allow.
508
+ */
509
+ export function sandboxProfile(input: {
510
+ writeRoots: readonly string[];
511
+ denyReadRoots: readonly string[];
512
+ denyReadFiles: readonly string[];
513
+ }): string {
514
+ const subpaths = (values: readonly string[]): string =>
515
+ values.map((v) => `(subpath ${JSON.stringify(v)})`).join(" ");
516
+ const literals = (values: readonly string[]): string =>
517
+ values.map((v) => `(literal ${JSON.stringify(v)})`).join(" ");
518
+ return [
519
+ "(version 1)",
520
+ ";; omp-conductor per-run profile (#125). Regenerated per dispatch.",
521
+ "(allow default)",
522
+ ";; Writable: this run's checkout and its own scratch, and nothing else.",
523
+ "(deny file-write*)",
524
+ `(allow file-write* ${subpaths(input.writeRoots)} ` +
525
+ `(literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") ` +
526
+ `(literal "/dev/dtracehelper") (regex #"^/dev/tty"))`,
527
+ ";; The credential paths an environment variable cannot hide, plus the",
528
+ ";; sibling run checkouts — cross-run separation on macOS is the profile's",
529
+ ";; job, because sandbox-exec does not change the uid.",
530
+ ...(input.denyReadRoots.length === 0 && input.denyReadFiles.length === 0
531
+ ? []
532
+ : [
533
+ `(deny file-read* ${subpaths(input.denyReadRoots)}${
534
+ input.denyReadFiles.length === 0 ? "" : ` ${literals(input.denyReadFiles)}`
535
+ })`,
536
+ ]),
537
+ ";; Last, so it outranks the denials above: a run must still be able to read",
538
+ ";; its OWN checkout even when the workspace root that contains it is denied",
539
+ ";; wholesale. SBPL takes the last matching rule, so ordering is the grant.",
540
+ `(allow file-read* ${subpaths(input.writeRoots)})`,
541
+ ";; The login keychain, which is reached over mach and not through the filesystem.",
542
+ '(deny mach-lookup (global-name "com.apple.SecurityServer") ' +
543
+ '(global-name "com.apple.securityd") (global-name "com.apple.securityd.xpc"))',
544
+ "",
545
+ ].join("\n");
546
+ }
547
+
548
+ /** `sandbox-exec` prefix for an already-written profile. */
549
+ export function sandboxExecArgv(profilePath: string, command: readonly string[]): string[] {
550
+ return ["sandbox-exec", "-f", profilePath, ...command];
551
+ }
552
+
553
+ // -------------------------------------------------------- capability probing
554
+
555
+ /** The five capability sets and the one flag that makes an empty set stick. */
556
+ export interface CapabilityState {
557
+ prm: string;
558
+ eff: string;
559
+ inh: string;
560
+ amb: string;
561
+ bnd: string;
562
+ noNewPrivs: number;
563
+ uid?: number;
564
+ gid?: number;
565
+ }
566
+
567
+ const CAP_FIELDS: Record<string, keyof CapabilityState> = {
568
+ CapPrm: "prm",
569
+ CapEff: "eff",
570
+ CapInh: "inh",
571
+ CapAmb: "amb",
572
+ CapBnd: "bnd",
573
+ };
574
+
575
+ /**
576
+ * Parse `/proc/<pid>/status`. Pure, so the acceptance assertions in #125 can be
577
+ * pinned against a captured fixture as well as run live inside a worker.
578
+ *
579
+ * Returns `undefined` on a kernel that reports no capability fields at all
580
+ * (and on macOS, where there is no procfs) — an absence the caller must handle
581
+ * as "cannot assert", never as "all clear".
582
+ */
583
+ export function parseProcStatusCaps(text: string): CapabilityState | undefined {
584
+ const caps: Partial<CapabilityState> = { noNewPrivs: 0 };
585
+ let sawCap = false;
586
+ for (const line of text.split("\n")) {
587
+ const sep = line.indexOf(":");
588
+ if (sep === -1) continue;
589
+ const key = line.slice(0, sep);
590
+ const value = line.slice(sep + 1).trim();
591
+ const field = CAP_FIELDS[key];
592
+ if (field !== undefined) {
593
+ (caps as Record<string, unknown>)[field] = value;
594
+ sawCap = true;
595
+ continue;
596
+ }
597
+ if (key === "NoNewPrivs") caps.noNewPrivs = Number.parseInt(value, 10) || 0;
598
+ // "Uid:\treal\teffective\tsaved\tfs" — the effective id is the one that
599
+ // decides what this process may open, so it is the one asserted.
600
+ if (key === "Uid" || key === "Gid") {
601
+ const parts = value.split(/\s+/);
602
+ const effective = Number.parseInt(parts[1] ?? parts[0] ?? "", 10);
603
+ if (Number.isFinite(effective)) {
604
+ if (key === "Uid") caps.uid = effective;
605
+ else caps.gid = effective;
606
+ }
607
+ }
608
+ }
609
+ if (!sawCap) return undefined;
610
+ return {
611
+ prm: caps.prm ?? "",
612
+ eff: caps.eff ?? "",
613
+ inh: caps.inh ?? "",
614
+ amb: caps.amb ?? "",
615
+ bnd: caps.bnd ?? "",
616
+ noNewPrivs: caps.noNewPrivs ?? 0,
617
+ ...(caps.uid === undefined ? {} : { uid: caps.uid }),
618
+ ...(caps.gid === undefined ? {} : { gid: caps.gid }),
619
+ };
620
+ }
621
+
622
+ /** True for `0000000000000000` and any other all-zero spelling of an empty set. */
623
+ function emptyCapSet(value: string): boolean {
624
+ return value !== "" && /^0+$/.test(value);
625
+ }
626
+
627
+ /** Does a capability mask contain a given bit? `CAP_SETPCAP` is 8, `CAP_SETUID` 7. */
628
+ export function hasCapability(mask: string, bit: number): boolean {
629
+ if (mask === "") return false;
630
+ let value: bigint;
631
+ try {
632
+ value = BigInt(`0x${mask}`);
633
+ } catch {
634
+ return false;
635
+ }
636
+ return (value & (1n << BigInt(bit))) !== 0n;
637
+ }
638
+
639
+ export const CAP_SETGID = 6;
640
+ export const CAP_SETUID = 7;
641
+ export const CAP_SETPCAP = 8;
642
+ export const CAP_CHOWN = 0;
643
+ /**
644
+ * Needed because `applyRunOwnership` chowns a tree to the slot principal and
645
+ * only then chmods it — at which point the daemon is no longer the owner, and
646
+ * membership in `conductor-daemon` does not authorise `chmod`. Reordering does
647
+ * not help: `chown` clears the setgid bit, so the `g+s` that makes the group
648
+ * inheritable has to come after it. Without this capability the very first
649
+ * ownership handoff fails EPERM, and only a root CI run hides it.
650
+ */
651
+ export const CAP_FOWNER = 3;
652
+
653
+ /**
654
+ * Everything wrong with a session child's capability state, as a list of
655
+ * sentences. Empty means the child is as unprivileged as this host allows.
656
+ *
657
+ * `boundingSetDropped` is the probe's own answer, not a guess: when the host
658
+ * refused `CAP_SETPCAP` a non-empty `CapBnd` is the documented residual rather
659
+ * than a failure, and asserting it unconditionally would turn a correctly
660
+ * configured fallback host into a red build.
661
+ */
662
+ export function capabilityViolations(
663
+ caps: CapabilityState,
664
+ opts: { boundingSetDropped: boolean },
665
+ ): string[] {
666
+ const wrong: string[] = [];
667
+ for (const [name, value] of [
668
+ ["CapPrm", caps.prm],
669
+ ["CapEff", caps.eff],
670
+ ["CapInh", caps.inh],
671
+ ["CapAmb", caps.amb],
672
+ ] as const) {
673
+ if (!emptyCapSet(value)) wrong.push(`${name} is ${value || "(absent)"}, expected all zero`);
674
+ }
675
+ if (opts.boundingSetDropped && !emptyCapSet(caps.bnd)) {
676
+ wrong.push(`CapBnd is ${caps.bnd || "(absent)"}, expected all zero on a host granting CAP_SETPCAP`);
677
+ }
678
+ if (caps.noNewPrivs !== 1) wrong.push(`NoNewPrivs is ${String(caps.noNewPrivs)}, expected 1`);
679
+ return wrong;
680
+ }
681
+
682
+ /** A POSIX identity as `id(1)` reports it. */
683
+ export interface Identity {
684
+ uid: number;
685
+ user: string;
686
+ gid: number;
687
+ group: string;
688
+ groups: { id: number; name: string }[];
689
+ }
690
+
691
+ const ID_PART = /^(uid|gid)=(\d+)\(([^)]*)\)$/;
692
+
693
+ /**
694
+ * Parse `id` output. Pure, because the identity assertion in #125 is
695
+ * "the *whole* identity, not just an absence" — a test that only checks
696
+ * `conductor-daemon` is missing also passes a launcher that forgot to replace
697
+ * the supplementary list at all, which is the bug it exists to catch.
698
+ */
699
+ export function parseIdOutput(text: string): Identity | undefined {
700
+ const fields = text.trim().split(/\s+/);
701
+ let uid: number | undefined;
702
+ let user = "";
703
+ let gid: number | undefined;
704
+ let group = "";
705
+ const groups: { id: number; name: string }[] = [];
706
+ for (const field of fields) {
707
+ const simple = ID_PART.exec(field);
708
+ if (simple !== null) {
709
+ const value = Number.parseInt(simple[2] ?? "", 10);
710
+ if (simple[1] === "uid") {
711
+ uid = value;
712
+ user = simple[3] ?? "";
713
+ } else {
714
+ gid = value;
715
+ group = simple[3] ?? "";
716
+ }
717
+ continue;
718
+ }
719
+ if (!field.startsWith("groups=")) continue;
720
+ for (const entry of field.slice("groups=".length).split(",")) {
721
+ const m = /^(\d+)\(([^)]*)\)$/.exec(entry);
722
+ if (m === null) continue;
723
+ groups.push({ id: Number.parseInt(m[1] ?? "", 10), name: m[2] ?? "" });
724
+ }
725
+ }
726
+ if (uid === undefined || gid === undefined) return undefined;
727
+ return { uid, user, gid, group, groups };
728
+ }
729
+
730
+ /**
731
+ * Everything wrong with a launched child's identity. Pins all three facts the
732
+ * spec calls for: the uid is the slot principal, the gid is that slot's *own*
733
+ * primary group, and the supplementary list is exactly the shared runs group.
734
+ */
735
+ export function identityViolations(
736
+ actual: Identity,
737
+ expected: { principal: SlotPrincipal; sharedGroup?: string },
738
+ ): string[] {
739
+ const shared = expected.sharedGroup ?? CONDUCTOR_GROUPS.runs;
740
+ const wrong: string[] = [];
741
+ if (actual.uid !== expected.principal.uid) {
742
+ wrong.push(`uid is ${String(actual.uid)}, expected ${String(expected.principal.uid)} (${expected.principal.user})`);
743
+ }
744
+ if (actual.gid !== expected.principal.gid) {
745
+ wrong.push(
746
+ `gid is ${String(actual.gid)}, expected ${String(expected.principal.gid)} (${expected.principal.group})`,
747
+ );
748
+ }
749
+ // The slot's own primary group reappears in `groups=` on every libc; the
750
+ // shared runs group is the only *additional* member allowed.
751
+ const extra = actual.groups
752
+ .map((g) => g.name)
753
+ .filter((name) => name !== shared && name !== expected.principal.group);
754
+ if (extra.length > 0) {
755
+ wrong.push(`supplementary groups include ${extra.join(", ")}, expected only ${shared}`);
756
+ }
757
+ if (!actual.groups.some((g) => g.name === shared)) {
758
+ wrong.push(`not a member of ${shared} — the child cannot read the shared mirror`);
759
+ }
760
+ if (actual.groups.some((g) => g.name === CONDUCTOR_GROUPS.daemon)) {
761
+ wrong.push(`is a member of ${CONDUCTOR_GROUPS.daemon} — that group is what keeps sibling runs out`);
762
+ }
763
+ return wrong;
764
+ }
765
+
766
+ // ---------------------------------------------------------------- host probe
767
+
768
+ /** What this host can actually enforce, decided once at daemon startup. */
769
+ export interface HostProbe {
770
+ mechanism: IsolationMechanism;
771
+ /**
772
+ * Why the stronger mechanisms were unavailable, in probe order. Read by the
773
+ * refusal message so an operator who asked for `per-run` on a host that
774
+ * cannot do it is told the missing piece rather than "isolation unavailable".
775
+ */
776
+ reasons: string[];
777
+ /** Real, named weaknesses of the mechanism that *was* selected. */
778
+ residuals: string[];
779
+ /** Whether the launcher may pass `--bounding-set=-all`. See {@link setprivArgv}. */
780
+ boundingSetDropped: boolean;
781
+ /** Worker slot principals, one per concurrent slot. Empty unless `uid-pool`. */
782
+ slots: SlotPrincipal[];
783
+ /** The orchestrator's own distinct account. Absent unless `uid-pool`. */
784
+ orchestrator?: SlotPrincipal;
785
+ /**
786
+ * The resolved `conductor-runs` gid, so the mirror can be handed the group
787
+ * every slot principal reads it through. Absent when the group is not
788
+ * defined, which is also why the mechanism is never `uid-pool` then.
789
+ */
790
+ runsGid?: number;
791
+ /**
792
+ * The resolved `conductor-daemon` gid. Carried rather than taken from the
793
+ * daemon's own `egid`, which is a different group entirely: the shipped unit
794
+ * runs `User=fleet`/`Group=fleet` with `conductor-daemon` only supplementary,
795
+ * so `egid` is `fleet` and handing that to {@link applyRunOwnership} would
796
+ * make every run repo `<slot>:fleet` — not the documented layout, readable by
797
+ * anyone else in `fleet`, and validated by a probe that checked a group the
798
+ * modes never used.
799
+ */
800
+ daemonGid?: number;
801
+ }
802
+
803
+ export interface ProbeOptions {
804
+ /** How many worker slots to provision principals for (`maxConcurrentWorkers`). */
805
+ slots: number;
806
+ platform?: NodeJS.Platform;
807
+ exec?: Exec;
808
+ /** `/proc/self/status` text for the daemon itself. Injected for tests. */
809
+ selfStatus?: () => string | undefined;
810
+ /** Prefix for slot account names. Pinned by the provisioning docs. */
811
+ accountPrefix?: string;
812
+ /**
813
+ * The daemon's own live credentials. Injected so the membership decision can
814
+ * be tested on a host that has neither conductor group; production reads the
815
+ * running process, which is the whole point (see {@link daemonGroupViolations}).
816
+ */
817
+ credentials?: () => DaemonCredentials;
818
+ }
819
+
820
+ /** This process's real group credentials, as the kernel sees them right now. */
821
+ export function liveDaemonCredentials(): DaemonCredentials {
822
+ return {
823
+ egid: typeof process.getegid === "function" ? process.getegid() : -1,
824
+ groups: typeof process.getgroups === "function" ? process.getgroups() : [],
825
+ };
826
+ }
827
+
828
+ const DEFAULT_ACCOUNT_PREFIX = "conductor-agent-";
829
+
830
+ function readSelfStatus(): string | undefined {
831
+ try {
832
+ return readFileSync("/proc/self/status", "utf8");
833
+ } catch {
834
+ return undefined;
835
+ }
836
+ }
837
+
838
+ /**
839
+ * The daemon's own *live* credentials. Read from the running process, never
840
+ * from the passwd/group database — see {@link daemonGroupViolations}.
841
+ */
842
+ export interface DaemonCredentials {
843
+ egid: number;
844
+ groups: readonly number[];
845
+ }
846
+
847
+ /** `getent group` output → gid, or `undefined` when the group is not defined. */
848
+ export function parseGetentGid(output: string, group: string): number | undefined {
849
+ for (const line of output.split("\n")) {
850
+ const parts = line.split(":");
851
+ if (parts[0] !== group) continue;
852
+ const gid = Number.parseInt(parts[2] ?? "", 10);
853
+ if (Number.isFinite(gid)) return gid;
854
+ }
855
+ return undefined;
856
+ }
857
+
858
+ /**
859
+ * Why this daemon must not be reported as `uid-pool`, from its own credentials.
860
+ *
861
+ * **Existence is not membership, and this distinction can brick a fleet
862
+ * silently.** `getent group conductor-daemon` succeeds on a host where the
863
+ * daemon account is not in the group at all; the probe then reports `uid-pool`,
864
+ * the daemon chowns every run repo to `<slot>:conductor-daemon 2770`, and locks
865
+ * *itself* out of push, salvage and reclaim — after ownership handoff, which is
866
+ * the point of no return. The symptom is unexplained permission errors at the
867
+ * next settle, not a probe failure.
868
+ *
869
+ * It is not a hypothetical either: supplementary group membership is fixed when
870
+ * a process starts, so `usermod -aG conductor-daemon fleet` without a service
871
+ * restart leaves a live daemon whose credentials lack the group while every
872
+ * database check passes. That is the ordinary upgrade path.
873
+ *
874
+ * Pure, and takes the group list as data, so the decision is testable on a host
875
+ * that has neither group.
876
+ *
877
+ * Note what is *not* checked here: whether each slot principal is really in
878
+ * `conductor-runs`. That cannot be read from this process's credentials, and
879
+ * the database would be the same weak evidence this function exists to reject —
880
+ * so it is proven instead by the launcher smoke test, which reads the identity
881
+ * a real launched child actually ends up with.
882
+ */
883
+ export function daemonGroupViolations(
884
+ creds: DaemonCredentials,
885
+ resolved: { daemonGid?: number; runsGid?: number },
886
+ ): string[] {
887
+ const wrong: string[] = [];
888
+ const member = (gid: number): boolean => creds.egid === gid || creds.groups.includes(gid);
889
+
890
+ if (resolved.daemonGid === undefined) {
891
+ wrong.push(`group ${CONDUCTOR_GROUPS.daemon} is not defined on this host`);
892
+ } else if (!member(resolved.daemonGid)) {
893
+ wrong.push(
894
+ `the daemon is not an effective member of ${CONDUCTOR_GROUPS.daemon} (gid ` +
895
+ `${String(resolved.daemonGid)}) — it would chown every run repo to a group it cannot use, and lock ` +
896
+ `itself out of push, salvage and reclaim. Supplementary groups are fixed at process start: run ` +
897
+ `\`usermod -aG ${CONDUCTOR_GROUPS.daemon} <daemon-account>\` and RESTART the service.`,
898
+ );
899
+ }
900
+ if (resolved.runsGid === undefined) {
901
+ wrong.push(`group ${CONDUCTOR_GROUPS.runs} is not defined on this host`);
902
+ }
903
+ return wrong;
904
+ }
905
+
906
+ /** Resolve one slot account to its numeric ids, or say why it could not be. */
907
+ async function resolvePrincipal(
908
+ exec: Exec,
909
+ slot: string,
910
+ user: string,
911
+ ): Promise<{ principal: SlotPrincipal } | { missing: string }> {
912
+ const id = await exec(["id", user]);
913
+ if (id.code !== 0) return { missing: `account ${user} does not exist` };
914
+ const parsed = parseIdOutput(id.stdout);
915
+ if (parsed === undefined) return { missing: `could not read the identity of ${user}` };
916
+ return {
917
+ principal: { slot, user, uid: parsed.uid, gid: parsed.gid, group: parsed.group },
918
+ };
919
+ }
920
+
921
+ /**
922
+ * Decide the mechanism, then **prove it** before reporting it available.
923
+ *
924
+ * The smoke test is the part that is not optional. A mechanism whose launcher
925
+ * argv the host rejects — a `setpriv` too old for `--no-new-privs`, a slot
926
+ * account the daemon may not become, a mutually-exclusive group flag — looks
927
+ * exactly like an available mechanism at startup and then fails *every*
928
+ * dispatch at run time, which is the worst way to learn it. So the composed
929
+ * argv is run against `id` and the resulting identity is checked; anything
930
+ * short of the expected identity resolves the mechanism down rather than
931
+ * leaving a fleet that cannot dispatch.
932
+ */
933
+ export async function probeHost(opts: ProbeOptions): Promise<HostProbe> {
934
+ const exec = opts.exec ?? spawnCaptured;
935
+ const platform = opts.platform ?? process.platform;
936
+ const prefix = opts.accountPrefix ?? DEFAULT_ACCOUNT_PREFIX;
937
+ const credentials = opts.credentials ?? liveDaemonCredentials;
938
+ const reasons: string[] = [];
939
+
940
+ if (platform === "darwin") return probeDarwin(exec, reasons);
941
+ if (platform !== "linux") {
942
+ reasons.push(`${platform} has no supported per-run isolation mechanism`);
943
+ return { mechanism: "none", reasons, residuals: [], boundingSetDropped: false, slots: [] };
944
+ }
945
+
946
+ const status = (opts.selfStatus ?? readSelfStatus)();
947
+ const caps = status === undefined ? undefined : parseProcStatusCaps(status);
948
+ const root = caps?.uid === 0;
949
+ const canSetuid = root || (caps !== undefined && hasCapability(caps.prm, CAP_SETUID) && hasCapability(caps.prm, CAP_SETGID));
950
+ // Ownership handoff needs both: CAP_CHOWN to give the tree away, CAP_FOWNER
951
+ // to keep chmod-ing it afterwards. Probed rather than assumed, because the
952
+ // failure lands at the first dispatch's point of no return.
953
+ const canOwn =
954
+ root || (caps !== undefined && hasCapability(caps.prm, CAP_CHOWN) && hasCapability(caps.prm, CAP_FOWNER));
955
+ const boundingSetDropped = root || (caps !== undefined && hasCapability(caps.prm, CAP_SETPCAP));
956
+
957
+ const setpriv = await exec(["setpriv", "--help"]);
958
+ const haveSetpriv = setpriv.code === 0;
959
+
960
+ if (!canOwn) {
961
+ reasons.push(
962
+ "the daemon holds neither CAP_CHOWN nor CAP_FOWNER in its permitted set — it could hand a run tree to " +
963
+ "its slot principal and then be unable to chmod it, failing at the point of no return. Add " +
964
+ "AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP to the unit",
965
+ );
966
+ }
967
+ if (!canSetuid) {
968
+ reasons.push(
969
+ "the daemon holds neither CAP_SETUID nor CAP_SETGID in its permitted set — " +
970
+ "add AmbientCapabilities=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_SETPCAP to the unit",
971
+ );
972
+ }
973
+ if (!haveSetpriv) {
974
+ reasons.push(
975
+ "setpriv (util-linux) is not on PATH — a raw spawn({uid,gid}) is deliberately not a fallback, " +
976
+ "because a child launched that way can hold CAP_SETUID and setuid() back",
977
+ );
978
+ }
979
+
980
+ if (canSetuid && canOwn && haveSetpriv) {
981
+ const slots: SlotPrincipal[] = [];
982
+ let unresolved: string | undefined;
983
+ for (let i = 0; i < opts.slots; i += 1) {
984
+ const found = await resolvePrincipal(exec, String(i), `${prefix}${String(i)}`);
985
+ if ("missing" in found) {
986
+ unresolved = found.missing;
987
+ break;
988
+ }
989
+ slots.push(found.principal);
990
+ }
991
+ const orch = unresolved === undefined ? await resolvePrincipal(exec, "orch", `${prefix}orch`) : undefined;
992
+ if (orch !== undefined && "missing" in orch) unresolved = orch.missing;
993
+
994
+ if (unresolved !== undefined) {
995
+ reasons.push(`${unresolved} — provision the slot accounts as described in the README`);
996
+ } else if (orch !== undefined && "principal" in orch) {
997
+ const groups = await exec(["getent", "group", CONDUCTOR_GROUPS.daemon, CONDUCTOR_GROUPS.runs]);
998
+ // The database answers "does the group exist"; the running process
999
+ // answers "may this daemon use it". Only the second one is load-bearing.
1000
+ const wrongGroups = daemonGroupViolations(credentials(), {
1001
+ ...(parseGetentGid(groups.stdout, CONDUCTOR_GROUPS.daemon) === undefined
1002
+ ? {}
1003
+ : { daemonGid: parseGetentGid(groups.stdout, CONDUCTOR_GROUPS.daemon) }),
1004
+ ...(parseGetentGid(groups.stdout, CONDUCTOR_GROUPS.runs) === undefined
1005
+ ? {}
1006
+ : { runsGid: parseGetentGid(groups.stdout, CONDUCTOR_GROUPS.runs) }),
1007
+ });
1008
+ if (wrongGroups.length > 0) {
1009
+ // Without both groups the layout either locks the daemon out of the run
1010
+ // repos it must fetch and reclaim, or lets the runs into each other's.
1011
+ reasons.push(...wrongGroups);
1012
+ } else {
1013
+ const smoke = await smokeTestLauncher(exec, slots[0] ?? orch.principal, boundingSetDropped);
1014
+ if (smoke === undefined) {
1015
+ return {
1016
+ mechanism: "uid-pool",
1017
+ reasons,
1018
+ residuals: boundingSetDropped
1019
+ ? []
1020
+ : [
1021
+ "CAP_SETPCAP was not granted, so the child's capability bounding set is not emptied. " +
1022
+ "It is inert — NoNewPrivs blocks both routes to acquiring anything in it — but it is not zero.",
1023
+ "A run can read another run's git objects through the shared mirror. Bounded and by design: " +
1024
+ "same source, no write path, no credential.",
1025
+ ],
1026
+ boundingSetDropped,
1027
+ slots,
1028
+ orchestrator: orch.principal,
1029
+ ...(parseGetentGid(groups.stdout, CONDUCTOR_GROUPS.runs) === undefined
1030
+ ? {}
1031
+ : { runsGid: parseGetentGid(groups.stdout, CONDUCTOR_GROUPS.runs) }),
1032
+ ...(parseGetentGid(groups.stdout, CONDUCTOR_GROUPS.daemon) === undefined
1033
+ ? {}
1034
+ : { daemonGid: parseGetentGid(groups.stdout, CONDUCTOR_GROUPS.daemon) }),
1035
+ };
1036
+ }
1037
+ reasons.push(`the launcher smoke test failed: ${smoke}`);
1038
+ }
1039
+ }
1040
+ }
1041
+
1042
+ // Degraded, and named as such. It bounds cross-run accidents by group and
1043
+ // mode; it does not stop a determined same-uid escape, and no string in this
1044
+ // package may say otherwise.
1045
+ // Live credentials again, not the database: a daemon that was added to the
1046
+ // group without a restart cannot use it, and group-mode's entire claim is
1047
+ // that the group separates the checkouts.
1048
+ const runsEntry = await exec(["getent", "group", CONDUCTOR_GROUPS.runs]);
1049
+ const runsGid = parseGetentGid(runsEntry.stdout, CONDUCTOR_GROUPS.runs);
1050
+ const live = credentials();
1051
+ if (runsGid !== undefined && (live.egid === runsGid || live.groups.includes(runsGid))) {
1052
+ return {
1053
+ mechanism: "group-mode",
1054
+ reasons,
1055
+ residuals: [
1056
+ "group-mode runs sessions as the daemon's own uid. It separates run checkouts by group and mode, " +
1057
+ "and it does NOT contain a determined bash escape: same-uid code reaches the daemon's credential.",
1058
+ ],
1059
+ boundingSetDropped: false,
1060
+ slots: [],
1061
+ };
1062
+ }
1063
+ reasons.push(
1064
+ `the daemon is not an effective member of ${CONDUCTOR_GROUPS.runs}, so even group-mode is unavailable`,
1065
+ );
1066
+ return { mechanism: "none", reasons, residuals: [], boundingSetDropped: false, slots: [] };
1067
+ }
1068
+
1069
+ /** Runs the exact composed argv against `id`. Returns a reason, or `undefined` on success. */
1070
+ async function smokeTestLauncher(
1071
+ exec: Exec,
1072
+ principal: SlotPrincipal,
1073
+ boundingSet: boolean,
1074
+ ): Promise<string | undefined> {
1075
+ const argv = setprivArgv(principal, { boundingSet }, ["id"]);
1076
+ const out = await exec(argv);
1077
+ if (out.code !== 0) {
1078
+ return `${argv.join(" ")} exited ${String(out.code)}: ${out.stderr.trim() || "no output"}`;
1079
+ }
1080
+ const identity = parseIdOutput(out.stdout);
1081
+ if (identity === undefined) return `could not parse the identity the launcher produced: ${out.stdout.trim()}`;
1082
+ const wrong = identityViolations(identity, { principal });
1083
+ return wrong.length === 0 ? undefined : wrong.join("; ");
1084
+ }
1085
+
1086
+ /**
1087
+ * macOS. `sandbox-exec` is deprecated by Apple and still the only thing on a
1088
+ * stock dev host that denies a *read*, which is what this boundary needs: the
1089
+ * profile is a kernel-enforced MAC layer, not an environment variable, so
1090
+ * `~/.ssh`, the login keychain and the operator's `gh` config genuinely become
1091
+ * unreachable to the session. Verified against the #125 probe list on
1092
+ * darwin 25.5.0 while writing this.
1093
+ */
1094
+ async function probeDarwin(exec: Exec, reasons: string[]): Promise<HostProbe> {
1095
+ const smoke = await exec(["sandbox-exec", "-p", "(version 1)(allow default)", "/usr/bin/true"]);
1096
+ if (smoke.code !== 0) {
1097
+ reasons.push(
1098
+ `sandbox-exec is unavailable or refused a trivial profile (${smoke.stderr.trim() || `exit ${String(smoke.code)}`})`,
1099
+ );
1100
+ return { mechanism: "none", reasons, residuals: [], boundingSetDropped: false, slots: [] };
1101
+ }
1102
+ return {
1103
+ mechanism: "sandbox-exec",
1104
+ reasons,
1105
+ residuals: [
1106
+ "sandbox-exec runs the session as the operator's own uid. Reads of the credential paths and writes " +
1107
+ "outside the run's checkout are denied by the kernel, but anything the profile does not name is allowed, " +
1108
+ "and Apple has deprecated the interface. A fleet host should use uid-pool on Linux.",
1109
+ ],
1110
+ boundingSetDropped: false,
1111
+ slots: [],
1112
+ };
1113
+ }
1114
+
1115
+ /**
1116
+ * The refusal an operator sees when they asked for a boundary this host cannot
1117
+ * build. Fail-closed, and it only ever fires because they asked by name.
1118
+ */
1119
+ export function boundaryRefusal(probe: HostProbe): string {
1120
+ const missing = probe.reasons.length === 0 ? ["no mechanism was found"] : probe.reasons;
1121
+ return (
1122
+ `credentials.isolation is "per-run" but this host offers no per-run mechanism: ${missing.join("; ")}. ` +
1123
+ `Fix the host, or set credentials.isolation to "none" — which dispatches, and which status reports as an ` +
1124
+ `unprotected fleet.`
1125
+ );
1126
+ }
1127
+
1128
+ // ------------------------------------------------------------------ mcp audit
1129
+
1130
+ /**
1131
+ * One MCP server that would carry a credential into a session, and why we say
1132
+ * so. `reason` is user-facing: dispatch refuses with it, and a refusal that
1133
+ * only said "credential-bearing server found" would leave the operator hunting
1134
+ * through four config files.
1135
+ */
1136
+ export interface McpServerRef {
1137
+ name: string;
1138
+ source: string;
1139
+ reason: string;
1140
+ }
1141
+
1142
+ /** Looks like a GitHub-facing server, by any of the three ways one is spelled. */
1143
+ function looksGitHub(name: string, entry: Record<string, unknown>): boolean {
1144
+ const haystack = [
1145
+ name,
1146
+ String(entry["command"] ?? ""),
1147
+ Array.isArray(entry["args"]) ? entry["args"].join(" ") : "",
1148
+ String(entry["url"] ?? ""),
1149
+ ]
1150
+ .join(" ")
1151
+ .toLowerCase();
1152
+ return haystack.includes("github");
1153
+ }
1154
+
1155
+ /** Environment/header keys that name a credential rather than a setting. */
1156
+ const SECRET_KEY = /(TOKEN|^PAT$|_PAT$|SECRET|PASSWORD|APIKEY|API_KEY|CREDENTIAL|AUTHORIZATION)/i;
1157
+ /** GitHub's own token prefixes, which are unambiguous wherever they appear. */
1158
+ const GITHUB_TOKEN_VALUE = /\b(gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,})\b/;
1159
+
1160
+ /**
1161
+ * Which servers in one parsed `mcp.json` would hand a session a credential.
1162
+ *
1163
+ * Pure and separate from the file reading, for the reason every other decision
1164
+ * in this package is: the interesting cases (a PAT inlined in `args`, a bearer
1165
+ * header, an `env` key named `GITHUB_TOKEN` with a `${...}` placeholder) are
1166
+ * worth pinning as data, and none of them needs a filesystem.
1167
+ *
1168
+ * A GitHub server with no credential material is *allowed*: it is reaching the
1169
+ * public API unauthenticated, which is exactly what item 6 of #125 permits.
1170
+ */
1171
+ export function auditMcpConfig(parsed: unknown, source: string): McpServerRef[] {
1172
+ if (parsed === null || typeof parsed !== "object") return [];
1173
+ const servers = (parsed as Record<string, unknown>)["mcpServers"];
1174
+ if (servers === null || typeof servers !== "object") return [];
1175
+
1176
+ const found: McpServerRef[] = [];
1177
+ for (const [name, value] of Object.entries(servers as Record<string, unknown>)) {
1178
+ if (value === null || typeof value !== "object") continue;
1179
+ const entry = value as Record<string, unknown>;
1180
+ const github = looksGitHub(name, entry);
1181
+
1182
+ const holders: [string, unknown][] = [
1183
+ ["env", entry["env"]],
1184
+ ["headers", entry["headers"]],
1185
+ ];
1186
+ let reason: string | undefined;
1187
+ for (const [where, holder] of holders) {
1188
+ if (holder === null || typeof holder !== "object") continue;
1189
+ for (const [key, raw] of Object.entries(holder as Record<string, unknown>)) {
1190
+ const text = typeof raw === "string" ? raw : "";
1191
+ if (GITHUB_TOKEN_VALUE.test(text)) {
1192
+ reason = `${where}.${key} holds a literal GitHub token`;
1193
+ break;
1194
+ }
1195
+ if (github && SECRET_KEY.test(key)) {
1196
+ reason = `${where}.${key} carries a credential for a GitHub-facing server`;
1197
+ break;
1198
+ }
1199
+ }
1200
+ if (reason !== undefined) break;
1201
+ }
1202
+ if (reason === undefined) {
1203
+ const argv = Array.isArray(entry["args"]) ? entry["args"].join(" ") : "";
1204
+ const inline = `${String(entry["command"] ?? "")} ${argv} ${String(entry["url"] ?? "")}`;
1205
+ if (GITHUB_TOKEN_VALUE.test(inline)) reason = "a GitHub token is inlined in its command line";
1206
+ }
1207
+ if (reason !== undefined) found.push({ name, source, reason });
1208
+ }
1209
+ return found;
1210
+ }
1211
+
1212
+ /**
1213
+ * The files the harness would discover for a session, in its own order: the
1214
+ * checkout first, then the *agent principal's* config root — which is the
1215
+ * redirected `$HOME` from {@link prepareSessionEnvRoot}, never the operator's.
1216
+ */
1217
+ export function mcpConfigPaths(roots: { agentHome: string; cwd: string }): string[] {
1218
+ return [
1219
+ join(roots.cwd, ".mcp.json"),
1220
+ join(roots.cwd, ".omp", "mcp.json"),
1221
+ join(roots.agentHome, ".omp", "agent", "mcp.json"),
1222
+ join(roots.agentHome, ".mcp.json"),
1223
+ ];
1224
+ }
1225
+
1226
+ /** Reads and audits every discoverable config. Unreadable files are not findings. */
1227
+ export function auditMcpRoots(roots: { agentHome: string; cwd: string }): McpServerRef[] {
1228
+ const found: McpServerRef[] = [];
1229
+ for (const path of mcpConfigPaths(roots)) {
1230
+ let parsed: unknown;
1231
+ try {
1232
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1233
+ } catch {
1234
+ // Absent or malformed: the harness will not load servers from it either.
1235
+ continue;
1236
+ }
1237
+ found.push(...auditMcpConfig(parsed, path));
1238
+ }
1239
+ return found;
1240
+ }
1241
+
1242
+ /**
1243
+ * The named error dispatch refuses with. `undefined` when the discovered set is
1244
+ * clean, which is the normal case and must stay silent.
1245
+ */
1246
+ export function mcpRefusal(found: readonly McpServerRef[]): string | undefined {
1247
+ if (found.length === 0) return undefined;
1248
+ const list = found.map((f) => `${f.name} (${f.source}: ${f.reason})`).join("; ");
1249
+ return (
1250
+ `credential-bearing MCP server configured for a session: ${list}. ` +
1251
+ `An MCP server carrying its own PAT re-opens the hole the per-run principal closes, so dispatch refuses ` +
1252
+ `rather than running with it. Move the server out of the agent principal's config root, or strip its credential.`
1253
+ );
1254
+ }
1255
+
1256
+ // --------------------------------------------------- run filesystem ownership
1257
+
1258
+ /**
1259
+ * Hand one run's tree to its slot principal, keeping the daemon's own access.
1260
+ *
1261
+ * Owner `conductor-agent-<slot>`, group `conductor-daemon`, directories `2770`
1262
+ * and files `0660`. The setgid bit is what makes it hold: without it every file
1263
+ * the worker creates lands in the worker's own group and the daemon's access
1264
+ * becomes silently partial — discovered, if at all, when a salvage or a
1265
+ * `conductor_push` fails in production rather than at provisioning time.
1266
+ *
1267
+ * The daemon deliberately gets write, not just read: it fetches the run branch,
1268
+ * runs salvage, and reclaims the tree at settlement, and reclaim needs write on
1269
+ * the directories. Only the daemon is in that group, so this is not a widening
1270
+ * — and a slot principal must never be added to it.
1271
+ */
1272
+ export function applyRunOwnership(root: string, principal: SlotPrincipal, daemonGid: number): void {
1273
+ // Collected during the walk and set in one pass at the end, because Bun's
1274
+ // `fs.chmodSync` **silently masks the mode to 0o777** (measured on Bun
1275
+ // 1.3.14): `chmodSync(dir, 0o2770)` leaves 0o770 and raises nothing, and
1276
+ // `fs.constants.S_ISGID` is undefined there so the usual workaround folds to
1277
+ // the same value. Losing that bit is not cosmetic — it is what makes every
1278
+ // file the worker creates inherit `conductor-daemon`, and without it the
1279
+ // daemon's access to a run's own output is silently partial, which surfaces
1280
+ // as a failed salvage in production rather than here.
1281
+ const directories: string[] = [];
1282
+
1283
+ const walk = (path: string): void => {
1284
+ let stat: Stats;
1285
+ try {
1286
+ stat = lstatSync(path);
1287
+ } catch {
1288
+ return;
1289
+ }
1290
+ // Before the chown and the chmod, not merely before the recursion. `lstat`
1291
+ // does not follow a link but `chown` and `chmod` both do, so touching a
1292
+ // symlink here would reach through it: a link inside the checkout pointing
1293
+ // anywhere outside would become a write primitive aimed out of the jail,
1294
+ // exercised by the daemon with its capabilities. For a function whose whole
1295
+ // job is confining a run, that is the worst possible bug, so the link
1296
+ // itself is left exactly as the worker found it.
1297
+ if (stat.isSymbolicLink()) return;
1298
+
1299
+ chownSync(path, principal.uid, daemonGid);
1300
+ if (stat.isDirectory()) {
1301
+ chmodSync(path, 0o770);
1302
+ directories.push(path);
1303
+ for (const entry of readdirSync(path)) walk(join(path, entry));
1304
+ return;
1305
+ }
1306
+ // Execute is preserved, never granted: a flat `0o660` strips `+x` from
1307
+ // every script in the checkout — `setup.sh`, anything in `node_modules/.bin`,
1308
+ // whatever a configured gate invokes — and the run's own gates then fail
1309
+ // with "permission denied" the moment ownership changes hands, reading like
1310
+ // a toolchain fault rather than a chmod. Owner and group are granted
1311
+ // together so the daemon group keeps parity with the owner, which is the
1312
+ // same reasoning as the `2770` on directories.
1313
+ const executable = (stat.mode & 0o111) === 0 ? 0 : 0o110;
1314
+ chmodSync(path, 0o660 | executable);
1315
+ };
1316
+ walk(root);
1317
+ setgidDirectories(directories);
1318
+ }
1319
+
1320
+ /**
1321
+ * Make the shared mirror readable by every run principal, and writable by none.
1322
+ *
1323
+ * Owner stays the daemon, group becomes `conductor-runs`, directories `2750`
1324
+ * and files `0640`. Without this the whole per-run repository model does not
1325
+ * work: a run's git dir borrows the mirror's objects through `alternates`, and
1326
+ * a mirror the slot principal cannot read makes every worker's repository look
1327
+ * corrupt — `fatal: cannot change to .../repo.git: Permission denied` — which
1328
+ * reads like a broken clone rather than a permissions layout.
1329
+ *
1330
+ * It was missed once already, and could not have been caught by a fixture: the
1331
+ * adversarial suite *documents* the mirror as "0750 daemon:conductor-runs" and
1332
+ * expects the read to be **allowed** as the known residual, but nothing
1333
+ * established that layout, so the read was denied on the first real uid-pool
1334
+ * host the suite ever ran on.
1335
+ *
1336
+ * Read-only is the point. The mirror is the one thing every run shares, so a
1337
+ * writable one would let any run rewrite the objects every other run builds on.
1338
+ * That a run can *read* another run's objects here is the documented residual:
1339
+ * same source, no write path, no credential.
1340
+ */
1341
+ export function applyMirrorReadAccess(root: string, runsGid: number): void {
1342
+
1343
+ const directories: string[] = [];
1344
+
1345
+ const walk = (path: string): void => {
1346
+ let stat: Stats;
1347
+ try {
1348
+ stat = lstatSync(path);
1349
+ } catch {
1350
+ return;
1351
+ }
1352
+ // Same reasoning as applyRunOwnership: lstat does not follow a link but
1353
+ // chown and chmod both do, so touching one here would reach outside the
1354
+ // mirror with the daemon's privileges.
1355
+ if (stat.isSymbolicLink()) return;
1356
+
1357
+ chownSync(path, -1, runsGid);
1358
+ if (stat.isDirectory()) {
1359
+ chmodSync(path, 0o750);
1360
+ directories.push(path);
1361
+ for (const entry of readdirSync(path)) walk(join(path, entry));
1362
+ return;
1363
+ }
1364
+ // Git keeps hooks in here, and a hook that lost `+x` is a silently broken
1365
+ // repository. Preserved, never granted — exactly as on the run side.
1366
+ const executable = (stat.mode & 0o111) === 0 ? 0 : 0o010;
1367
+ chmodSync(path, 0o640 | executable);
1368
+ };
1369
+ walk(root);
1370
+ // Setgid so objects the daemon fetches later keep the group; a fetch that
1371
+ // wrote root-group objects would re-break alternates one pack at a time.
1372
+ setgidDirectories(directories);
1373
+ }
1374
+
1375
+ /**
1376
+ * Why a run principal could not walk down to the shared mirror, if it cannot.
1377
+ *
1378
+ * Group-readable objects are useless if the path to them is not searchable, and
1379
+ * `mirrorRoot` defaults to a directory under the state dir — which is `0700`
1380
+ * daemon-owned, so by default it is not.
1381
+ *
1382
+ * This **refuses instead of widening**, and the distinction is the whole point.
1383
+ * Adding `+x` to the state directory would look like a one-bit fix and would
1384
+ * quietly publish everything else in there: `openStore` creates
1385
+ * `conductor.db` and its `-wal`/`-shm` siblings with the process umask, so a
1386
+ * searchable parent makes fleet, run and report history readable by every local
1387
+ * account. SQLite recreates those files at runtime, so chmod-ing them once does
1388
+ * not hold either. The safe layout is a mirror root that is not inside the
1389
+ * private tree, and the operator has to say where — so this names the problem
1390
+ * and the fix rather than guessing.
1391
+ */
1392
+ export function mirrorTraversalRefusal(mirrorPath: string, privateRoot: string): string | undefined {
1393
+ const stop = resolve(privateRoot);
1394
+ let cursor = resolve(mirrorPath);
1395
+ const blocked: string[] = [];
1396
+ // Ancestors only, and only up to the filesystem root — a `privateRoot` that
1397
+ // is not actually an ancestor must not send this walking off widening or
1398
+ // inspecting unrelated directories.
1399
+ while (cursor !== dirname(cursor)) {
1400
+ cursor = dirname(cursor);
1401
+ try {
1402
+ if ((lstatSync(cursor).mode & 0o001) === 0) blocked.push(cursor);
1403
+ } catch {
1404
+ break;
1405
+ }
1406
+ if (cursor === stop) break;
1407
+ }
1408
+ if (blocked.length === 0) return undefined;
1409
+ return (
1410
+ `run principals cannot reach the shared mirror ${mirrorPath}: ${blocked.join(", ")} ` +
1411
+ `${blocked.length === 1 ? "is" : "are"} not searchable. The run repository borrows the mirror's objects ` +
1412
+ `through git alternates, so every checkout would look corrupt rather than forbidden. Do NOT chmod the ` +
1413
+ `state directory to fix this — it holds conductor.db and its WAL, which SQLite recreates with the process ` +
1414
+ `umask, so a searchable parent publishes fleet history to every local account. Set "mirrorRoot" to a ` +
1415
+ `directory outside the private state tree (for example /srv/conductor/mirrors, mode 0711) and restart.`
1416
+ );
1417
+ }
1418
+
1419
+ /**
1420
+ * Adds the setgid bit to directories, in as few `chmod` calls as the argument
1421
+ * limit allows. Shelled out only because the runtime's own `chmod` cannot
1422
+ * express it — see the note in {@link applyRunOwnership}.
1423
+ */
1424
+ function setgidDirectories(directories: readonly string[]): void {
1425
+ const CHUNK = 256;
1426
+ for (let i = 0; i < directories.length; i += CHUNK) {
1427
+ const chunk = directories.slice(i, i + CHUNK);
1428
+ const out = Bun.spawnSync(["chmod", "g+s", ...chunk], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
1429
+ if (out.exitCode !== 0) {
1430
+ throw new Error(
1431
+ `could not set the setgid bit on ${String(chunk.length)} run directories ` +
1432
+ `(first: ${chunk[0] ?? "?"}): ${out.stderr.toString().trim() || `chmod exited ${String(out.exitCode)}`}. ` +
1433
+ `Without it the worker's own files do not inherit ${CONDUCTOR_GROUPS.daemon} and the daemon loses ` +
1434
+ `access to the run's output.`,
1435
+ );
1436
+ }
1437
+ }
1438
+ }
1439
+
1440
+ /**
1441
+ * The name of the probe file {@link verifyDaemonAccess} writes. A dotfile
1442
+ * inside the run's own tree, removed immediately, and matched by the managed
1443
+ * `info/exclude` block so a racing `git status` never reports it.
1444
+ */
1445
+ export const DAEMON_ACCESS_PROBE = ".conductor-access-probe";
1446
+
1447
+ /**
1448
+ * Prove the daemon can still reach a run tree it has just handed to a slot
1449
+ * principal — by writing, reading and removing, not by inspecting modes.
1450
+ *
1451
+ * This runs *after* {@link applyRunOwnership}, which is the point of no return:
1452
+ * once the tree is `<slot>:conductor-daemon 2770`, a daemon that is not an
1453
+ * effective member of that group has locked itself out of `pushRunBranch`,
1454
+ * salvage and settlement reclaim. Mode arithmetic cannot see that, because the
1455
+ * modes are correct — it is the daemon's own credentials that are wrong.
1456
+ *
1457
+ * The realistic way to get there is the ordinary upgrade path: supplementary
1458
+ * groups are established when a process starts, so an operator who runs
1459
+ * `usermod -aG conductor-daemon fleet` without restarting the unit has a live
1460
+ * daemon whose credentials lack the group while every `getent` check passes.
1461
+ * Returns the actionable sentence, not a bare `EACCES`.
1462
+ */
1463
+ export function verifyDaemonAccess(root: string): string | undefined {
1464
+ const probe = join(root, DAEMON_ACCESS_PROBE);
1465
+ try {
1466
+ writeFileSync(probe, "conductor", { mode: 0o660 });
1467
+ const read = readFileSync(probe, "utf8");
1468
+ rmSync(probe, { force: true });
1469
+ if (read !== "conductor") return `wrote ${probe} but read back ${JSON.stringify(read)}`;
1470
+ return undefined;
1471
+ } catch (err) {
1472
+ rmSync(probe, { force: true });
1473
+ return (
1474
+ `the daemon cannot write inside ${root} after handing it to ${"the run's slot principal"}: ` +
1475
+ `${err instanceof Error ? err.message : String(err)}. The usual cause is that the daemon is not an ` +
1476
+ `EFFECTIVE member of ${CONDUCTOR_GROUPS.daemon} — supplementary groups are fixed at process start, so ` +
1477
+ `restart the service after \`usermod -aG ${CONDUCTOR_GROUPS.daemon} <daemon-account>\`.`
1478
+ );
1479
+ }
1480
+ }
1481
+
1482
+ // ------------------------------------------------- the privileged publish path
1483
+
1484
+ /** One run's own repository, as the daemon addresses it. */
1485
+ export interface RunRepoRef {
1486
+ repo: RepoTarget;
1487
+ /** The run's own git repository — not a linked worktree of the mirror (#125 1b). */
1488
+ runRepoPath: string;
1489
+ branch: string;
1490
+ }
1491
+
1492
+ export type PushOutcome = { ok: true; sha: string } | { ok: false; stderr: string };
1493
+ export type PrOutcome = { ok: true; url: string } | { ok: false; stderr: string };
1494
+
1495
+ /** A clone URL can carry a token, and it lands in git's own error text. */
1496
+ const URL_USERINFO = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^\s/@]+@/g;
1497
+
1498
+ /** Blank the `user:token@` part of any URL before a string reaches a log or a human. */
1499
+ export function scrubUserinfo(text: string): string {
1500
+ return text.replace(URL_USERINFO, "$1***@");
1501
+ }
1502
+
1503
+ function mirrorPath(project: Pick<ProjectConfig, "mirrorRoot">, repo: RepoTarget): string {
1504
+ return join(project.mirrorRoot, `${repo.name}.git`);
1505
+ }
1506
+
1507
+ /** GitHub slug parsed off the clone URL, falling back to the routing name. */
1508
+ export function repoSlugFor(repo: RepoTarget): string {
1509
+ const m = /(?:[:/])([^/:]+\/[^/]+?)(?:\.git)?$/.exec(repo.cloneUrl);
1510
+ return m?.[1] ?? repo.name;
1511
+ }
1512
+
1513
+ /**
1514
+ * Publish a run's branch: run repo → mirror → GitHub, **fast-forward only**.
1515
+ *
1516
+ * This is the whole reason a worker needs no credential (#125 item 3). The
1517
+ * worker commits into its own repository, which is durable, local and readable
1518
+ * by the daemon through {@link CONDUCTOR_GROUPS.daemon}; everything that
1519
+ * touches the network happens here, on the privileged side, through
1520
+ * {@link credentialedEnv}.
1521
+ *
1522
+ * Both hops are fast-forward only and there is no force path anywhere in this
1523
+ * package. The fetch refspec deliberately carries **no leading `+`** — that
1524
+ * single character is the difference between "advance the mirror's copy of this
1525
+ * branch" and "make the mirror's copy whatever the run says it is", and a run
1526
+ * repo is the one place in this system that model-executed code fully controls.
1527
+ * The push carries no `--force` and no `--force-with-lease`: a lease is still a
1528
+ * force, and a rejected push is a decision for a human, not a retry.
1529
+ *
1530
+ * A rejection — non-fast-forward, protected ref, unauthorised — comes back as a
1531
+ * value carrying git's stderr verbatim, because the caller settles the run
1532
+ * `failed` on it and a paraphrase is worthless in that report.
1533
+ */
1534
+ export async function pushRunBranch(
1535
+ project: Pick<ProjectConfig, "mirrorRoot">,
1536
+ run: RunRepoRef,
1537
+ exec: Exec = spawnCaptured,
1538
+ ): Promise<PushOutcome> {
1539
+ const mirror = mirrorPath(project, run.repo);
1540
+ const ref = `refs/heads/${run.branch}`;
1541
+ const env = credentialedEnv();
1542
+
1543
+ const fetched = await exec(["git", "-C", mirror, "fetch", "--no-tags", run.runRepoPath, `${ref}:${ref}`], { env });
1544
+ if (fetched.code !== 0) {
1545
+ return { ok: false, stderr: scrubUserinfo(fetched.stderr.trim() || fetched.stdout.trim() || `git fetch exited ${String(fetched.code)}`) };
1546
+ }
1547
+
1548
+ const resolved = await exec(["git", "-C", mirror, "rev-parse", ref], { env });
1549
+ if (resolved.code !== 0) {
1550
+ return { ok: false, stderr: scrubUserinfo(resolved.stderr.trim() || `git rev-parse ${ref} exited ${String(resolved.code)}`) };
1551
+ }
1552
+ const sha = resolved.stdout.trim();
1553
+
1554
+ const pushed = await exec(["git", "-C", mirror, "push", "origin", `${ref}:${ref}`], { env });
1555
+ if (pushed.code !== 0) {
1556
+ return { ok: false, stderr: scrubUserinfo(pushed.stderr.trim() || pushed.stdout.trim() || `git push exited ${String(pushed.code)}`) };
1557
+ }
1558
+ return { ok: true, sha };
1559
+ }
1560
+
1561
+ /**
1562
+ * Open the run's pull request from the privileged side.
1563
+ *
1564
+ * The body goes over stdin, never argv: a body with newlines, backticks or a
1565
+ * leading `-` is ordinary, and interpolating it into a command line is how a
1566
+ * worker's report would become an argument.
1567
+ */
1568
+ export async function openRunPr(
1569
+ project: Pick<ProjectConfig, "mirrorRoot">,
1570
+ run: RunRepoRef,
1571
+ opts: { title: string; body: string; base: string },
1572
+ exec: Exec = spawnCaptured,
1573
+ ): Promise<PrOutcome> {
1574
+ void project;
1575
+ const out = await exec(
1576
+ [
1577
+ "gh",
1578
+ "pr",
1579
+ "create",
1580
+ "--repo",
1581
+ repoSlugFor(run.repo),
1582
+ "--head",
1583
+ run.branch,
1584
+ "--base",
1585
+ opts.base,
1586
+ "--title",
1587
+ opts.title,
1588
+ "--body-file",
1589
+ "-",
1590
+ ],
1591
+ { env: credentialedEnv(), stdin: opts.body },
1592
+ );
1593
+ if (out.code !== 0) {
1594
+ return { ok: false, stderr: scrubUserinfo(out.stderr.trim() || out.stdout.trim() || `gh pr create exited ${String(out.code)}`) };
1595
+ }
1596
+ const url = /https:\/\/github\.com\/\S+\/pull\/\d+/.exec(out.stdout)?.[0];
1597
+ if (url === undefined) {
1598
+ return { ok: false, stderr: `gh pr create succeeded but printed no PR URL: ${out.stdout.trim()}` };
1599
+ }
1600
+ return { ok: true, url };
1601
+ }
1602
+
1603
+ // ------------------------------------------------- composing one run's boundary
1604
+
1605
+ /** Everything the daemon knows about one session before it launches it. */
1606
+ export interface BoundaryRequest {
1607
+ probe: HostProbe;
1608
+ isolation: CredentialIsolation;
1609
+ role: SessionRole;
1610
+ /**
1611
+ * Worker slot index for a worker session; ignored for the orchestrator, which
1612
+ * has its own distinct account precisely so it cannot reach a run checkout.
1613
+ */
1614
+ slot?: number;
1615
+ /** Private config tree for this session, already materialised. */
1616
+ envRoot: string;
1617
+ /** The only paths this session may write. A worker's is its checkout. */
1618
+ writeRoots: readonly string[];
1619
+ /** Credential paths the macOS profile must deny outright. */
1620
+ denyReadRoots?: readonly string[];
1621
+ denyReadFiles?: readonly string[];
1622
+ readToken?: string;
1623
+ /** Where a generated `sandbox-exec` profile is written. */
1624
+ profilePath?: string;
1625
+ }
1626
+
1627
+ /**
1628
+ * Does what the host can build satisfy what the operator asked for?
1629
+ *
1630
+ * The one place the ordering between the two vocabularies is written down, so
1631
+ * the dispatch gate, the boundary builder, `status` and the setup wizard cannot
1632
+ * disagree about whether a host is good enough. Stronger always satisfies
1633
+ * weaker — `uid-pool` honours a `group-mode` request — but never the reverse,
1634
+ * which is the whole point: a `per-run` request that `group-mode` could satisfy
1635
+ * would be a downgrade nobody consented to (#125).
1636
+ */
1637
+ export function mechanismSatisfies(
1638
+ isolation: CredentialIsolation,
1639
+ mechanism: IsolationMechanism,
1640
+ ): boolean {
1641
+ if (isolation === "none") return true;
1642
+ if (isolation === "group-mode") return mechanism !== "none";
1643
+ return mechanism === "uid-pool" || mechanism === "sandbox-exec";
1644
+ }
1645
+
1646
+ /**
1647
+ * Compose the launcher and environment for one session.
1648
+ *
1649
+ * Note what this does *not* do: it never downgrades. If the operator asked for
1650
+ * `per-run` and the probe found nothing, the caller must refuse the dispatch —
1651
+ * silently returning an unprotected boundary here is exactly the failure the
1652
+ * issue calls "never the silent default", and it would be invisible because the
1653
+ * session would start and work.
1654
+ */
1655
+ export function buildSessionBoundary(req: BoundaryRequest): SessionBoundary {
1656
+ const paths = prepareSessionEnvRoot(req.envRoot);
1657
+ const env = sessionEnv(paths, req.readToken === undefined ? {} : { readToken: req.readToken });
1658
+
1659
+ if (req.isolation === "none") {
1660
+ return { mechanism: "none", launcher: [], env };
1661
+ }
1662
+
1663
+ // Defensive, not decorative: dispatch is gated on the same predicate, so
1664
+ // reaching here means a caller skipped the gate. Returning a weaker boundary
1665
+ // would start the session and work, which is exactly how a downgrade becomes
1666
+ // invisible — so this throws instead.
1667
+ if (!mechanismSatisfies(req.isolation, req.probe.mechanism)) {
1668
+ throw new Error(
1669
+ `credentials.isolation is "${req.isolation}" but this host's mechanism is "${req.probe.mechanism}": ` +
1670
+ `${req.probe.reasons.join("; ") || "no stronger mechanism was found"}. Refusing to build a weaker ` +
1671
+ `boundary than the one configured.`,
1672
+ );
1673
+ }
1674
+
1675
+ if (req.probe.mechanism === "uid-pool") {
1676
+ const principal =
1677
+ req.role === "orchestrator" ? req.probe.orchestrator : req.probe.slots[req.slot ?? 0];
1678
+ if (principal === undefined) {
1679
+ throw new Error(
1680
+ `no slot principal for a ${req.role} session (slot ${String(req.slot ?? 0)}) — the probe reported ` +
1681
+ `uid-pool with ${String(req.probe.slots.length)} slot account(s); this is a provisioning mismatch, ` +
1682
+ `not something to run unprotected.`,
1683
+ );
1684
+ }
1685
+ return {
1686
+ mechanism: "uid-pool",
1687
+ // An empty command yields exactly the prefix, `--` included: the caller
1688
+ // appends the real argv after it.
1689
+ // The orchestrator gets NO supplementary group. `conductor-runs` exists
1690
+ // to let a worker read the shared mirror through git alternates; the
1691
+ // orchestrator has no checkout and no alternates, so granting it would
1692
+ // hand over the one residual the design explicitly says it does not get.
1693
+ launcher: setprivArgv(
1694
+ principal,
1695
+ {
1696
+ boundingSet: req.probe.boundingSetDropped,
1697
+ ...(req.role === "orchestrator" ? { sharedGroup: false as const } : {}),
1698
+ },
1699
+ [],
1700
+ ),
1701
+ env,
1702
+ principal,
1703
+ };
1704
+ }
1705
+
1706
+ if (req.probe.mechanism === "sandbox-exec") {
1707
+ const profilePath = req.profilePath ?? join(req.envRoot, "sandbox.sb");
1708
+ writeFileSync(
1709
+ profilePath,
1710
+ sandboxProfile({
1711
+ // The session's own scratch and config tree are writable for the same
1712
+ // reason its checkout is: the harness writes its transcript, and a
1713
+ // profile that forgot it fails at session startup, not at a tool call.
1714
+ writeRoots: [...req.writeRoots, paths.home, paths.tmp, paths.ghConfigDir],
1715
+ denyReadRoots: req.denyReadRoots ?? [],
1716
+ denyReadFiles: req.denyReadFiles ?? [],
1717
+ }),
1718
+ { mode: 0o600 },
1719
+ );
1720
+ return { mechanism: "sandbox-exec", launcher: sandboxExecArgv(profilePath, []), env };
1721
+ }
1722
+
1723
+ // group-mode: same uid, separation by group and mode only. Only reachable
1724
+ // when the operator asked for it by name — `mechanismSatisfies` above refuses
1725
+ // to let a `per-run` request land here. No launcher, and no claim beyond what
1726
+ // the probe's own residual already says out loud.
1727
+ return { mechanism: "group-mode", launcher: [], env };
1728
+ }
1729
+
1730
+ /**
1731
+ * Which worker slot a run occupies, for the lifetime of that run.
1732
+ *
1733
+ * A pool rather than a counter because the slot *is* the principal: two live
1734
+ * runs holding the same index would run as the same uid and could write each
1735
+ * other's checkout, which is the cross-run property #124's metric forbids. The
1736
+ * pool is `maxConcurrentWorkers` wide, so acquiring never blocks in practice —
1737
+ * admission has already capped concurrency — but it returns `undefined` rather
1738
+ * than handing out a duplicate if it ever did.
1739
+ */
1740
+ export interface SlotPool {
1741
+ acquire(): number | undefined;
1742
+ release(slot: number): void;
1743
+ size(): number;
1744
+ }
1745
+
1746
+ export function createSlotPool(size: number): SlotPool {
1747
+ const taken = new Set<number>();
1748
+ return {
1749
+ acquire() {
1750
+ for (let i = 0; i < size; i += 1) {
1751
+ if (taken.has(i)) continue;
1752
+ taken.add(i);
1753
+ return i;
1754
+ }
1755
+ return undefined;
1756
+ },
1757
+ release(slot) {
1758
+ taken.delete(slot);
1759
+ },
1760
+ size: () => size,
1761
+ };
1762
+ }
1763
+
1764
+ /**
1765
+ * How `status` describes the live boundary. One sentence for the mechanism and
1766
+ * one line per residual, because "protected" with no qualifier is the claim
1767
+ * this whole issue exists to stop anyone making by accident.
1768
+ */
1769
+ export function describeBoundary(
1770
+ isolation: CredentialIsolation,
1771
+ probe: HostProbe,
1772
+ ): { headline: string; detail: string[] } {
1773
+ if (isolation === "none") {
1774
+ return {
1775
+ headline: `unprotected — credentials.isolation is "none"`,
1776
+ detail: [
1777
+ "Sessions run as the daemon's own user with its GitHub credential reachable. Environment scrubbing is",
1778
+ "in force and is accident-prevention only: same-uid code defeats it in one line.",
1779
+ ],
1780
+ };
1781
+ }
1782
+ if (!mechanismSatisfies(isolation, probe.mechanism)) {
1783
+ return {
1784
+ headline: `REFUSING DISPATCH — "${isolation}" requested, this host offers "${probe.mechanism}"`,
1785
+ detail: probe.reasons,
1786
+ };
1787
+ }
1788
+ if (probe.mechanism === "uid-pool") {
1789
+ return {
1790
+ headline: "per-run — uid-pool (one OS principal per run slot)",
1791
+ detail: probe.residuals,
1792
+ };
1793
+ }
1794
+ if (probe.mechanism === "sandbox-exec") {
1795
+ return { headline: "per-run — sandbox-exec (macOS dev host)", detail: probe.residuals };
1796
+ }
1797
+ // Reached only when the operator asked for group-mode by name, so the
1798
+ // headline states the weaker claim rather than dressing it as the boundary.
1799
+ return {
1800
+ headline: "group-mode — cross-run separation only, does NOT contain a bash escape",
1801
+ detail: probe.residuals,
1802
+ };
1803
+ }
1804
+
1805
+ // ------------------------------------------------------- the adversarial probe
1806
+
1807
+ /**
1808
+ * One check that the boundary holds, written the way an attacker would write it.
1809
+ *
1810
+ * Declared as data, and run through {@link runAdversarialProbes}, so the *same*
1811
+ * checks execute in two places: in CI against a fixture credential layout, and
1812
+ * from inside a real session. #125 asks for both, and two hand-written copies
1813
+ * would drift — the CI one would keep passing while the live one stopped being
1814
+ * run, which is the failure mode a regression suite exists to prevent.
1815
+ *
1816
+ * `expect` is part of the data because not every answer is "denied". A run can
1817
+ * read another run's git objects out of the shared mirror; that is the known,
1818
+ * documented residual, and a suite that asserted it was blocked would either be
1819
+ * wrong or would quietly be made to pass by weakening the mirror.
1820
+ */
1821
+ export interface AdversarialProbe {
1822
+ name: string;
1823
+ /** Shell rather than argv: several of these attacks *are* shell one-liners. */
1824
+ script: string;
1825
+ expect: "denied" | "allowed";
1826
+ /** What this probe defends, in the words of the issue that paid for it. */
1827
+ why: string;
1828
+ }
1829
+
1830
+ export interface ProbeTargets {
1831
+ /** The operator's real home — the credential store the session must not reach. */
1832
+ operatorHome: string;
1833
+ /** This run's own checkout. */
1834
+ checkout: string;
1835
+ /** `owner/repo`, for the push attempts. */
1836
+ repoSlug: string;
1837
+ /** A sibling run's checkout, when there is one to try. */
1838
+ siblingCheckout?: string;
1839
+ /** A sibling run's branch name, for the ref-write attempts. */
1840
+ siblingBranch?: string;
1841
+ /** The shared mirror, which is readable and must not be writable. */
1842
+ mirror?: string;
1843
+ }
1844
+
1845
+ /** The checks from #125's acceptance list, plus the cross-run ones from the spec. */
1846
+ export function adversarialProbes(t: ProbeTargets): AdversarialProbe[] {
1847
+ const q = (s: string): string => `'${s.replaceAll("'", `'\\''`)}'`;
1848
+ const probes: AdversarialProbe[] = [
1849
+ {
1850
+ name: "gh auth status",
1851
+ script: "gh auth status",
1852
+ expect: "denied",
1853
+ why: "the daemon authenticates by shelling out to gh; a session running the same binary reached the same credential",
1854
+ },
1855
+ {
1856
+ name: "gh with the operator's config dir",
1857
+ script: `GH_CONFIG_DIR=${q(join(t.operatorHome, ".config", "gh"))} gh auth status`,
1858
+ expect: "denied",
1859
+ why: "scrubbing GH_CONFIG_DIR is an environment variable; the config file has to be unreadable, not merely unreferenced",
1860
+ },
1861
+ {
1862
+ name: "read the operator's gh hosts.yml",
1863
+ script: `cat ${q(join(t.operatorHome, ".config", "gh", "hosts.yml"))}`,
1864
+ expect: "denied",
1865
+ why: "the oauth token lives in this file in plain text",
1866
+ },
1867
+ {
1868
+ name: "read ~/.git-credentials",
1869
+ script: `cat ${q(join(t.operatorHome, ".git-credentials"))}`,
1870
+ expect: "denied",
1871
+ why: "git's store helper keeps https credentials here in plain text",
1872
+ },
1873
+ {
1874
+ name: "list the operator's ~/.ssh",
1875
+ script: `ls ${q(join(t.operatorHome, ".ssh"))}`,
1876
+ expect: "denied",
1877
+ why: "a private key plus git@github.com needs no environment variable at all",
1878
+ },
1879
+ {
1880
+ name: "read ~/.npmrc",
1881
+ script: `cat ${q(join(t.operatorHome, ".npmrc"))}`,
1882
+ expect: "denied",
1883
+ why: "publish credentials are inside the same blast radius",
1884
+ },
1885
+ {
1886
+ name: "npm whoami",
1887
+ script: "npm whoami",
1888
+ expect: "denied",
1889
+ why: "the registry credential is reachable the same way the git one is",
1890
+ },
1891
+ {
1892
+ name: "ssh to github",
1893
+ script: "ssh -o BatchMode=yes -o ConnectTimeout=5 -T git@github.com",
1894
+ expect: "denied",
1895
+ why: "no usable key and no agent socket",
1896
+ },
1897
+ {
1898
+ name: "push over ssh",
1899
+ script: `git -C ${q(t.checkout)} push git@github.com:${t.repoSlug} HEAD`,
1900
+ expect: "denied",
1901
+ why: "the ssh route bypasses every gh and https control",
1902
+ },
1903
+ {
1904
+ name: "push with an explicit credential helper",
1905
+ script: `git -C ${q(t.checkout)} -c credential.helper=${process.platform === "darwin" ? "osxkeychain" : "store"} push https://github.com/${t.repoSlug} HEAD`,
1906
+ expect: "denied",
1907
+ why: "a helper named on the command line ignores GIT_CONFIG_GLOBAL entirely",
1908
+ },
1909
+ {
1910
+ name: "push to origin from the checkout",
1911
+ script: `git -C ${q(t.checkout)} push origin HEAD`,
1912
+ expect: "denied",
1913
+ why: "the ordinary push. It must fail, and the daemon must publish the same branch afterwards",
1914
+ },
1915
+ ];
1916
+
1917
+ if (t.siblingCheckout !== undefined) {
1918
+ probes.push(
1919
+ {
1920
+ name: "write a sibling run's checkout",
1921
+ script: `touch ${q(join(t.siblingCheckout, ".conductor-cross-run"))}`,
1922
+ expect: "denied",
1923
+ why: "a rogue run editing a sibling's work is what makes a shared agent account insufficient",
1924
+ },
1925
+ {
1926
+ name: "read a sibling run's checkout",
1927
+ script: `ls ${q(t.siblingCheckout)}`,
1928
+ expect: "denied",
1929
+ why: "run checkouts are 2770 <slot>:conductor-daemon, so a sibling slot cannot even list one",
1930
+ },
1931
+ );
1932
+ if (t.siblingBranch !== undefined) {
1933
+ probes.push(
1934
+ {
1935
+ name: "update a sibling run's ref",
1936
+ script: `git -C ${q(t.siblingCheckout)} update-ref refs/heads/${t.siblingBranch} HEAD`,
1937
+ expect: "denied",
1938
+ why: "the linked worktree this replaced gave every run write access to every other run's refs",
1939
+ },
1940
+ {
1941
+ name: "push a sibling run's branch",
1942
+ script: `git -C ${q(t.siblingCheckout)} push origin ${t.siblingBranch}`,
1943
+ expect: "denied",
1944
+ why: "publishing a sibling's branch under this run's name",
1945
+ },
1946
+ );
1947
+ }
1948
+ }
1949
+
1950
+ if (t.mirror !== undefined) {
1951
+ probes.push(
1952
+ {
1953
+ name: "read the shared mirror's objects",
1954
+ script: `git -C ${q(t.mirror)} rev-parse --git-dir`,
1955
+ expect: "allowed",
1956
+ why: "THE DOCUMENTED RESIDUAL: a run can read another run's commits out of the shared object store. Bounded — same source, no write path, no credential — and the price of not cloning per run",
1957
+ },
1958
+ {
1959
+ name: "write inside the shared mirror",
1960
+ script: `touch ${q(join(t.mirror, ".conductor-mirror-write"))}`,
1961
+ expect: "denied",
1962
+ why: "the mirror is 0750 daemon:conductor-runs — readable by every slot, writable by none",
1963
+ },
1964
+ {
1965
+ name: "delete a ref from the shared mirror",
1966
+ script: `git -C ${q(t.mirror)} update-ref -d refs/heads/nonexistent-probe`,
1967
+ expect: "denied",
1968
+ why: "rewriting the shared object store would reach every run at once",
1969
+ },
1970
+ );
1971
+ }
1972
+
1973
+ return probes;
1974
+ }
1975
+
1976
+ /** What one probe found. `contained` is true when the boundary behaved. */
1977
+ export interface ProbeResult {
1978
+ name: string;
1979
+ contained: boolean;
1980
+ expect: "denied" | "allowed";
1981
+ detail: string;
1982
+ why: string;
1983
+ }
1984
+
1985
+ /**
1986
+ * Run every probe through `exec`, which must place the command *inside* the
1987
+ * boundary — see {@link boundaryShell}. A probe is "contained" when the answer
1988
+ * matched its `expect`, so a suite reading all-contained is the boundary
1989
+ * holding and a suite reading otherwise is a named list of what got through.
1990
+ */
1991
+ export async function runAdversarialProbes(
1992
+ probes: readonly AdversarialProbe[],
1993
+ exec: (script: string) => Promise<{ code: number; stdout: string; stderr: string }>,
1994
+ ): Promise<ProbeResult[]> {
1995
+ const results: ProbeResult[] = [];
1996
+ for (const probe of probes) {
1997
+ const out = await exec(probe.script);
1998
+ const denied = out.code !== 0;
1999
+ results.push({
2000
+ name: probe.name,
2001
+ expect: probe.expect,
2002
+ contained: probe.expect === "denied" ? denied : !denied,
2003
+ detail: scrubUserinfo((out.stderr.trim() || out.stdout.trim() || `exit ${String(out.code)}`).slice(0, 400)),
2004
+ why: probe.why,
2005
+ });
2006
+ }
2007
+ return results;
2008
+ }
2009
+
2010
+ /**
2011
+ * Runs a shell command inside a session's boundary — the same launcher and the
2012
+ * same environment a real session gets. This is what makes the probe suite
2013
+ * evidence rather than a description: it exercises the composed argv, not a
2014
+ * reconstruction of it.
2015
+ */
2016
+ export function boundaryShell(
2017
+ boundary: SessionBoundary,
2018
+ exec: Exec = spawnCaptured,
2019
+ ): (script: string) => Promise<{ code: number; stdout: string; stderr: string }> {
2020
+ return (script) =>
2021
+ exec([...boundary.launcher, "/bin/sh", "-c", script], { env: boundary.env });
2022
+ }
2023
+
2024
+ /** The probes that got through, formatted for a test failure or an escalation. */
2025
+ export function escapedProbes(results: readonly ProbeResult[]): string[] {
2026
+ return results
2027
+ .filter((r) => !r.contained)
2028
+ .map((r) => `${r.name}: expected ${r.expect} — ${r.detail} (${r.why})`);
2029
+ }