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.
- package/README.md +733 -30
- package/package.json +1 -1
- package/src/board.ts +24 -5
- package/src/briefs/orchestrator.md +106 -20
- package/src/briefs/policy.md +48 -27
- package/src/briefs/worker.md +82 -31
- package/src/cli.ts +155 -2
- package/src/config.ts +669 -16
- package/src/confinement.ts +506 -25
- package/src/credentials.ts +2029 -0
- package/src/daemon.ts +1000 -32
- package/src/diff-flags.ts +696 -0
- package/src/escalate.ts +136 -9
- package/src/fleet.ts +71 -3
- package/src/omp.ts +471 -15
- package/src/orchestrator-tick.ts +42 -19
- package/src/orchestrator.ts +32 -5
- package/src/plugin.ts +267 -15
- package/src/release-policy.ts +191 -29
- package/src/reports.ts +440 -0
- package/src/session-host.ts +307 -0
- package/src/setup-host.ts +19 -1
- package/src/setup.ts +207 -18
- package/src/store.ts +506 -3
- package/src/tracker/github.ts +45 -0
- package/src/types.ts +847 -10
- package/src/usage.ts +726 -0
- package/src/verbs/actions.ts +142 -0
- package/src/verbs/client.ts +207 -0
- package/src/verbs/ledger.ts +77 -0
- package/src/verbs/protocol.ts +465 -0
- package/src/verbs/server.ts +1098 -0
- package/src/verbs/socket.ts +446 -0
- package/src/worker.ts +36 -7
- package/src/worktree.ts +202 -109
- package/systemd/omp-conductor.service.example +96 -8
|
@@ -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 "./
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
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
|