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