omp-conductor 0.3.25 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,446 @@
1
+ /**
2
+ * The authenticated local transport the mutation verbs ride on (#126).
3
+ *
4
+ * The permission layout is the whole security argument, and the obvious one
5
+ * does not work. A socket `chmod 0600` and `chown`ed to a run's principal is
6
+ * *unreachable* if its parent is the daemon's usual `0700`: connecting needs
7
+ * **search** (`+x`) on every path component, not read. So:
8
+ *
9
+ * - the parent directory is daemon-owned and mode **`0711`** — searchable by
10
+ * run principals, listable by none, and **writable by none but the daemon**;
11
+ * - each socket is `0600`, owned by the principal of the run it belongs to, so
12
+ * exactly one uid can connect, and the orchestrator's is a third distinct one;
13
+ * - the parent being non-writable is what stops a run unlinking a sibling's
14
+ * socket or binding an impostor listener in its place. A per-run directory
15
+ * owned by the run principal would hand back exactly that power, and is why
16
+ * the obvious layout is not used here.
17
+ *
18
+ * Two further rules make that layout hold rather than merely describe it:
19
+ * {@link validateSocketPath} refuses to bind under a path anyone else could
20
+ * have tampered with, and {@link peerVerdict} compares the connecting uid the
21
+ * kernel reports against the uid the daemon allocated for that run.
22
+ *
23
+ * Every decision in this file is a pure function of `lstat` results or of a
24
+ * credentials struct, so the adversarial cases are testable without root and
25
+ * without a second uid.
26
+ */
27
+
28
+ import { dlopen, FFIType, ptr, suffix } from "bun:ffi";
29
+ import { randomBytes } from "node:crypto";
30
+ import { chmodSync, chownSync, lstatSync, mkdirSync, rmSync } from "node:fs";
31
+ import type { Stats } from "node:fs";
32
+ import { dirname, isAbsolute, join, resolve } from "node:path";
33
+
34
+ /** Searchable by run principals, listable by none, writable only by the daemon. */
35
+ export const VERB_DIR_MODE = 0o711;
36
+
37
+ /** One uid may connect. The parent's `0711` is what makes that reachable. */
38
+ export const VERB_SOCKET_MODE = 0o600;
39
+
40
+ /**
41
+ * How long a socket path may be.
42
+ *
43
+ * `sun_path` is 104 bytes on darwin and 108 on Linux, *including* the
44
+ * terminator, and a bind past it fails with a truncation nobody reads as a
45
+ * path-length problem. Refusing at 100 leaves headroom on both and turns a
46
+ * confusing `ENAMETOOLONG` into a named dispatch refusal.
47
+ */
48
+ export const SOCKET_PATH_MAX = 100;
49
+
50
+ /** The daemon-owned parent every verb socket lives directly inside. */
51
+ export function verbSocketDir(root: string): string {
52
+ return join(root, "verbs");
53
+ }
54
+
55
+ /**
56
+ * Create (or repair the mode of) the socket parent.
57
+ *
58
+ * `chmod` on every call rather than only on create: a directory left `0700` by
59
+ * an older release, or widened by hand, would otherwise stay wrong until
60
+ * somebody deleted it — and the failure is silent in the safe direction for
61
+ * `0700` (nothing can connect) and silent in the unsafe direction for `0777`.
62
+ */
63
+ export function ensureVerbSocketDir(root: string): string {
64
+ const dir = verbSocketDir(root);
65
+ mkdirSync(dir, { recursive: true, mode: VERB_DIR_MODE });
66
+ chmodSync(dir, VERB_DIR_MODE);
67
+ return dir;
68
+ }
69
+
70
+ /**
71
+ * A per-run socket path with an unguessable suffix.
72
+ *
73
+ * Unguessable because the parent is unlistable: a run that cannot enumerate the
74
+ * directory and cannot guess a sibling's filename cannot even name the socket
75
+ * it is not allowed to open. The suffix is the second lock on a door whose
76
+ * first lock is `0600` ownership.
77
+ */
78
+ export function verbSocketPath(dir: string, label: string): string {
79
+ const slug = label.replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 24);
80
+ return join(dir, `${slug}-${randomBytes(8).toString("hex")}.sock`);
81
+ }
82
+
83
+ export type SocketPathFault =
84
+ | "not-absolute"
85
+ | "too-long"
86
+ | "missing"
87
+ | "symlink"
88
+ | "not-directory"
89
+ | "foreign-owner"
90
+ | "group-writable"
91
+ | "other-writable"
92
+ | "not-searchable";
93
+
94
+ export interface SocketPathProblem {
95
+ /** The offending path component, named so the refusal is actionable. */
96
+ component: string;
97
+ fault: SocketPathFault;
98
+ message: string;
99
+ }
100
+
101
+ /** Every ancestor of `path`, root first, excluding `path` itself. */
102
+ function ancestors(path: string): string[] {
103
+ const out: string[] = [];
104
+ let cursor = dirname(resolve(path));
105
+ for (;;) {
106
+ out.unshift(cursor);
107
+ const parent = dirname(cursor);
108
+ if (parent === cursor) break;
109
+ cursor = parent;
110
+ }
111
+ return out;
112
+ }
113
+
114
+ /**
115
+ * Verify nobody but the daemon could have tampered with where this socket is
116
+ * about to be bound. A failure **refuses dispatch** — it never degrades to
117
+ * binding anyway, because a socket under a directory a third party can write is
118
+ * a socket a third party can replace.
119
+ *
120
+ * Ownership is `daemonUid` **or root**. Requiring the daemon to own `/` and
121
+ * `/Users` is not a check, it is a condition that can never hold, and a check
122
+ * that always fails gets deleted by the next person who reads it. Root-owned
123
+ * ancestors are the operating system's, and a host whose `/usr` is hostile has
124
+ * already lost in ways no socket mode addresses.
125
+ *
126
+ * `lstat` rather than `stat`, deliberately: `stat` follows the very symlink
127
+ * this is looking for.
128
+ */
129
+ export function validateSocketPath(
130
+ path: string,
131
+ daemonUid: number,
132
+ lstat: (p: string) => Stats = lstatSync,
133
+ ): SocketPathProblem | undefined {
134
+ if (!isAbsolute(path)) {
135
+ return {
136
+ component: path,
137
+ fault: "not-absolute",
138
+ message: `verb socket path ${path} is not absolute`,
139
+ };
140
+ }
141
+ if (Buffer.byteLength(path) > SOCKET_PATH_MAX) {
142
+ return {
143
+ component: path,
144
+ fault: "too-long",
145
+ message: `verb socket path ${path} is ${Buffer.byteLength(path)} bytes, over the ${SOCKET_PATH_MAX}-byte limit a unix socket address allows`,
146
+ };
147
+ }
148
+
149
+ for (const component of ancestors(path)) {
150
+ let stat: Stats;
151
+ try {
152
+ stat = lstat(component);
153
+ } catch {
154
+ return {
155
+ component,
156
+ fault: "missing",
157
+ message: `verb socket parent ${component} does not exist or cannot be read`,
158
+ };
159
+ }
160
+ if (stat.isSymbolicLink()) {
161
+ return {
162
+ component,
163
+ fault: "symlink",
164
+ message: `verb socket parent ${component} is a symlink; a component that can be re-pointed is a socket that can be moved under the daemon`,
165
+ };
166
+ }
167
+ if (!stat.isDirectory()) {
168
+ return {
169
+ component,
170
+ fault: "not-directory",
171
+ message: `verb socket parent ${component} is not a directory`,
172
+ };
173
+ }
174
+ if (stat.uid !== daemonUid && stat.uid !== 0) {
175
+ return {
176
+ component,
177
+ fault: "foreign-owner",
178
+ message: `verb socket parent ${component} is owned by uid ${stat.uid}, neither the daemon (${daemonUid}) nor root`,
179
+ };
180
+ }
181
+ if ((stat.mode & 0o020) !== 0) {
182
+ return {
183
+ component,
184
+ fault: "group-writable",
185
+ message: `verb socket parent ${component} is group-writable (mode ${(stat.mode & 0o7777).toString(8)}); anyone in that group could replace the socket`,
186
+ };
187
+ }
188
+ if ((stat.mode & 0o002) !== 0) {
189
+ return {
190
+ component,
191
+ fault: "other-writable",
192
+ message: `verb socket parent ${component} is world-writable (mode ${(stat.mode & 0o7777).toString(8)}); anyone on this host could replace the socket`,
193
+ };
194
+ }
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ /**
200
+ * The first path component a *foreign* uid could not search through, or
201
+ * `undefined` when the whole chain is traversable.
202
+ *
203
+ * Checked rather than assumed, because this is the failure the layout in this
204
+ * file is most likely to hit in the field and the least likely to be noticed:
205
+ * connecting to a socket needs `+x` on **every** component, and the daemon's
206
+ * own state directory is `0700`. A run principal would then be refused at the
207
+ * state directory rather than at the socket, with an `EACCES` that names a
208
+ * directory nobody was thinking about.
209
+ *
210
+ * Only meaningful when the run has a distinct principal. Under one uid the
211
+ * daemon is the caller, so `0700` is traversable by definition — which is
212
+ * exactly why this cannot be left to be discovered on the first fleet that
213
+ * turns per-run principals on.
214
+ *
215
+ * Deliberately reported rather than repaired: the fix is `chmod o+x` on a
216
+ * directory that may be the operator's home, and a daemon that silently
217
+ * widened `$HOME` would be trading a legible refusal for a surprise.
218
+ */
219
+ export function traversalProblem(
220
+ path: string,
221
+ lstat: (p: string) => Stats = lstatSync,
222
+ ): SocketPathProblem | undefined {
223
+ for (const component of ancestors(path)) {
224
+ let stat: Stats;
225
+ try {
226
+ stat = lstat(component);
227
+ } catch {
228
+ return {
229
+ component,
230
+ fault: "missing",
231
+ message: `verb socket parent ${component} does not exist or cannot be read`,
232
+ };
233
+ }
234
+ if ((stat.mode & 0o001) === 0) {
235
+ return {
236
+ component,
237
+ fault: "not-searchable",
238
+ message:
239
+ `verb socket parent ${component} is mode ${(stat.mode & 0o7777).toString(8)}, which a run principal ` +
240
+ `cannot search through. Connecting needs +x on every component. Run: chmod o+x ${component}`,
241
+ };
242
+ }
243
+ }
244
+ return undefined;
245
+ }
246
+
247
+ /**
248
+ * Remove a stale socket at `path`, and only ever one the daemon itself placed.
249
+ *
250
+ * Called after {@link validateSocketPath} has passed, so the parent is known
251
+ * non-writable by anyone else — which is what makes "unlink and rebind" safe
252
+ * here and unsafe in `/tmp`. Nothing outside the daemon ever unlinks these.
253
+ */
254
+ export function unlinkStaleSocket(path: string): void {
255
+ rmSync(path, { force: true });
256
+ }
257
+
258
+ /**
259
+ * Hand a bound socket to its run's principal.
260
+ *
261
+ * Returns which guarantee is actually in force, because #126 asks the daemon to
262
+ * *state* its mechanism at startup rather than guess: with a principal the
263
+ * socket is one-uid; without one it is one-user, and the parent's `0711` plus
264
+ * the unguessable suffix are the whole story. Saying which is the difference
265
+ * between an audited boundary and a hopeful one.
266
+ */
267
+ export type SocketOwnership = "run-principal" | "daemon-user";
268
+
269
+ export function secureBoundSocket(
270
+ path: string,
271
+ principal: { uid: number; gid: number } | undefined,
272
+ chmod: (p: string, mode: number) => void = chmodSync,
273
+ chown: (p: string, uid: number, gid: number) => void = chownSync,
274
+ ): SocketOwnership {
275
+ chmod(path, VERB_SOCKET_MODE);
276
+ if (principal === undefined) return "daemon-user";
277
+ chown(path, principal.uid, principal.gid);
278
+ return "run-principal";
279
+ }
280
+
281
+ export interface PeerCredentials {
282
+ uid: number;
283
+ gid: number;
284
+ }
285
+
286
+ /**
287
+ * Read the connecting process's credentials off a connected socket fd, or
288
+ * `undefined` where the platform (or this runtime's socket object) does not
289
+ * expose them.
290
+ *
291
+ * `getpeereid` on darwin, `SO_PEERCRED` on Linux — both asked of the kernel,
292
+ * which is the point: the answer cannot be forged by the peer, unlike anything
293
+ * the peer could put in a payload.
294
+ */
295
+ export type PeerReader = (fd: number) => PeerCredentials | undefined;
296
+
297
+ interface LibcSymbols {
298
+ getpeereid?: (fd: number, uid: unknown, gid: unknown) => number;
299
+ getsockopt?: (fd: number, level: number, name: number, value: unknown, len: unknown) => number;
300
+ }
301
+
302
+ /** `SOL_SOCKET` / `SO_PEERCRED` on Linux. Both are ABI constants, not guesses. */
303
+ const SOL_SOCKET_LINUX = 1;
304
+ const SO_PEERCRED_LINUX = 17;
305
+
306
+ /**
307
+ * The peer reader for this host, or `undefined` when there is none.
308
+ *
309
+ * Resolved once at daemon start so the mechanism can be logged before the
310
+ * first socket exists, rather than discovered on the first connection. The
311
+ * `dlopen` is inside the try because it is the part that fails on a host
312
+ * without a loadable libc — the symbols this asks for are POSIX, but a
313
+ * hardened or unusual host is entitled to refuse, and "we could not ask" must
314
+ * never read as "the uid matched".
315
+ */
316
+ export function peerCredentialReader(): PeerReader | undefined {
317
+ const declarations: Record<string, { args: FFIType[]; returns: FFIType }> =
318
+ process.platform === "darwin"
319
+ ? { getpeereid: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 } }
320
+ : {
321
+ getsockopt: {
322
+ args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr],
323
+ returns: FFIType.i32,
324
+ },
325
+ };
326
+ // `libc.${suffix}` is `libc.so` on Linux, and that is a GNU **linker script**,
327
+ // not a shared object — `dlopen` refuses it, the reader silently resolves to
328
+ // undefined, and peer-credential verification is quietly absent on the one
329
+ // platform the fleet actually runs on. Measured on ubuntu-24.04. So the real
330
+ // sonames are tried first, and only then the generic name for musl and macOS.
331
+ const candidates =
332
+ process.platform === "linux"
333
+ ? ["libc.so.6", "libc.musl-x86_64.so.1", "libc.musl-aarch64.so.1", `libc.${suffix}`]
334
+ : [`libc.${suffix}`];
335
+
336
+ let symbols: LibcSymbols | undefined;
337
+ for (const lib of candidates) {
338
+ try {
339
+ symbols = dlopen(lib, declarations).symbols as LibcSymbols;
340
+ break;
341
+ } catch {
342
+ // Next candidate. Exhausting them all is a real answer — see the docstring:
343
+ // "we could not ask" must never read as "the uid matched".
344
+ }
345
+ }
346
+ if (symbols === undefined) return undefined;
347
+
348
+ if (process.platform === "darwin") {
349
+ const getpeereid = symbols.getpeereid;
350
+ if (getpeereid === undefined) return undefined;
351
+ return (fd) => {
352
+ const uid = new Uint32Array(1);
353
+ const gid = new Uint32Array(1);
354
+ if (getpeereid(fd, ptr(uid), ptr(gid)) !== 0) return undefined;
355
+ return { uid: uid[0] ?? -1, gid: gid[0] ?? -1 };
356
+ };
357
+ }
358
+
359
+ const getsockopt = symbols.getsockopt;
360
+ if (getsockopt === undefined) return undefined;
361
+ return (fd) => {
362
+ // struct ucred { pid_t pid; uid_t uid; gid_t gid; } — three 32-bit fields.
363
+ const cred = new Uint32Array(3);
364
+ const len = new Uint32Array([cred.byteLength]);
365
+ if (getsockopt(fd, SOL_SOCKET_LINUX, SO_PEERCRED_LINUX, ptr(cred), ptr(len)) !== 0) {
366
+ return undefined;
367
+ }
368
+ return { uid: cred[1] ?? -1, gid: cred[2] ?? -1 };
369
+ };
370
+ }
371
+
372
+ /**
373
+ * The file descriptor behind a `node:net` socket, or `undefined`.
374
+ *
375
+ * Reached through the handle rather than a public accessor because neither
376
+ * Node nor Bun exposes one, and narrowed here so exactly one place in this
377
+ * package knows that. An absent fd is not a failure — it means peer credentials
378
+ * cannot be read, which {@link peerVerdict} accounts for explicitly.
379
+ */
380
+ export function socketFd(socket: unknown): number | undefined {
381
+ const handle = (socket as { _handle?: { fd?: unknown } } | null)?._handle;
382
+ const fd = handle?.fd;
383
+ return typeof fd === "number" && fd >= 0 ? fd : undefined;
384
+ }
385
+
386
+ export type PeerVerdict =
387
+ | { ok: true; basis: "peer-uid"; uid: number }
388
+ | { ok: true; basis: "socket-ownership"; why: string }
389
+ | { ok: false; detail: string; peerUid: number; expectedUid: number };
390
+
391
+ /**
392
+ * Compare the kernel-reported peer against the uid the daemon allocated.
393
+ *
394
+ * The two `ok` bases are not the same guarantee and are deliberately not
395
+ * collapsed. `peer-uid` means the kernel vouched for the caller. `socket-
396
+ * ownership` means nobody could vouch and the whole guarantee is the `0600`
397
+ * socket under an unlistable, daemon-owned, non-writable parent — which is
398
+ * real, but it does not distinguish two runs sharing one uid. A caller that
399
+ * printed "verified" for both would be claiming something it never checked.
400
+ */
401
+ export function peerVerdict(
402
+ expected: { uid: number } | undefined,
403
+ peer: PeerCredentials | undefined,
404
+ ): PeerVerdict {
405
+ if (expected === undefined) {
406
+ return {
407
+ ok: true,
408
+ basis: "socket-ownership",
409
+ why: "this run has no distinct OS principal, so peer uid cannot tell two runs apart; the 0600 socket under the daemon-owned 0711 parent is the whole boundary",
410
+ };
411
+ }
412
+ if (peer === undefined) {
413
+ return {
414
+ ok: true,
415
+ basis: "socket-ownership",
416
+ why: "this host exposes no peer credentials, so the 0600 socket owned by the run principal is the whole boundary",
417
+ };
418
+ }
419
+ if (peer.uid !== expected.uid) {
420
+ return {
421
+ ok: false,
422
+ peerUid: peer.uid,
423
+ expectedUid: expected.uid,
424
+ detail: `uid ${peer.uid} connected to a socket allocated to uid ${expected.uid}`,
425
+ };
426
+ }
427
+ return { ok: true, basis: "peer-uid", uid: peer.uid };
428
+ }
429
+
430
+ /** One line naming what the transport can actually enforce on this host. */
431
+ export function transportBanner(
432
+ dir: string,
433
+ peerReader: PeerReader | undefined,
434
+ principals: boolean,
435
+ ): string {
436
+ const peers =
437
+ peerReader === undefined
438
+ ? `no peer-credential call on ${process.platform}`
439
+ : process.platform === "darwin"
440
+ ? "peer uid asserted with getpeereid"
441
+ : "peer uid asserted with SO_PEERCRED";
442
+ const owners = principals
443
+ ? "each socket 0600 and chowned to its run principal"
444
+ : "each socket 0600 under the daemon's own uid (no per-run principals on this host)";
445
+ return `verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ${owners}; ${peers}`;
446
+ }
package/src/worker.ts CHANGED
@@ -10,9 +10,9 @@
10
10
  * sliding into a merge queue.
11
11
  */
12
12
 
13
+ import type { SessionBoundary } from "./credentials.ts";
13
14
  import { createSession, disposeSession } from "./omp.ts";
14
- import type { ReleaseShape } from "./release-policy.ts";
15
- import type { Caps, ReleasePolicy, RunState } from "./types.ts";
15
+ import type { Caps, ReleaseShape, ResolvedGrants, RunState } from "./types.ts";
16
16
 
17
17
  /** Structured evidence fields from the worker's final report. */
18
18
  const PR_URL_PATTERN = /^pr:\s*(https:\/\/github\.com\/\S+\/pull\/\d+)\s*$/im;
@@ -46,10 +46,34 @@ export interface WorkerOpts {
46
46
  * the harness to pick, which is what an unconfigured project wants.
47
47
  */
48
48
  model?: string;
49
- /** Effective release/deploy gate for this session. */
50
- releasePolicy?: ReleasePolicy;
49
+ /**
50
+ * Effective per-shape release grants for this session. A worker is refused
51
+ * every shape whatever they say — see {@link SessionRole} — so this is passed
52
+ * for the audit trail and the wording of the refusal, not for permission.
53
+ */
54
+ releaseGrants?: ResolvedGrants;
51
55
  /** Durable audit sink for rejected release/deploy calls. */
52
56
  onReleaseBlocked?: (shape: ReleaseShape) => void;
57
+ /**
58
+ * The OS principal this run's session executes as (#125). The worker does not
59
+ * interpret it — it hands it to {@link createSession}, which is where the
60
+ * launcher lives. Omitted, the session is still a child process but runs as
61
+ * the daemon's own user: that is A1's shape and carries no security claim.
62
+ */
63
+ boundary?: SessionBoundary;
64
+ /**
65
+ * Control socket for that child. The daemon puts it inside the run's own
66
+ * boundary root, because a slot principal has to be able to reach it.
67
+ */
68
+ socketPath?: string;
69
+ /**
70
+ * The run's conductor verb socket (#126) — its only route to a push, a PR or
71
+ * any other mutation. Distinct from `socketPath`: that is the daemon's
72
+ * control channel to the child, this is the child's mediated way back out.
73
+ */
74
+ verbSocketPath?: string;
75
+ /** Where the session child's stdout/stderr go. Defaults to this process's stderr. */
76
+ onChildLog?: (line: string) => void;
53
77
  /**
54
78
  * Reads the effective ceiling at each turn boundary. Omitted, the configured
55
79
  * startup cap remains fixed for the run.
@@ -177,10 +201,15 @@ export async function runWorker(
177
201
  cwd: o.cwd,
178
202
  ...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
179
203
  ...(o.model === undefined ? {} : { model: o.model }),
180
- // Prevention half of #24: structured file tools cannot leave this worktree.
181
- confineToCwd: true,
182
- ...(o.releasePolicy === undefined ? {} : { releasePolicy: o.releasePolicy }),
204
+ // Prevention half of #24: as a worker, structured file tools cannot leave
205
+ // this worktree, and no release grant can ever reach this session (#122).
206
+ role: "worker",
207
+ ...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
183
208
  ...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
209
+ ...(o.boundary === undefined ? {} : { boundary: o.boundary }),
210
+ ...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
211
+ ...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
212
+ ...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
184
213
  });
185
214
 
186
215
  // Before the first turn, not after the last: a caller that only learns the