omp-conductor 0.4.5 → 0.5.1

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