omp-conductor 0.5.3 → 0.5.4
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 +31 -8
- package/package.json +1 -1
- package/src/daemon.ts +22 -4
- package/src/omp.ts +12 -0
- package/src/orchestrator.ts +4 -0
- package/src/verbs/server.ts +59 -6
- package/src/verbs/socket.ts +88 -17
- package/src/worker.ts +3 -0
package/README.md
CHANGED
|
@@ -2028,7 +2028,7 @@ dispatcher. The brief is explicit about the boundary:
|
|
|
2028
2028
|
| Add or update tests for behaviour it introduced. | Suppress a warning, delete an assertion, or special-case an input to make a check pass. |
|
|
2029
2029
|
| Run the repo's configured cheap gates, each from its listed `cwd`, over the whole tree. | Run docker or image builds, production builds, browser/e2e suites, or the full test suite on the shared host — CI owns the heavy gates. |
|
|
2030
2030
|
| Review its whole diff, then commit and publish once with `conductor_push`. One corrective push if CI is red. | Force-push, `git add -f`, or add AI/co-author attribution. There is no force path to reach: `conductor_push` publishes that run's branch fast-forward only and takes no other ref. Red twice means stop and report, not push a third time. |
|
|
2031
|
-
| Open a PR with `conductor_pr_create`, and poll CI to a verdict with `conductor_pr_status`. | Run `gh pr merge` — or reach `conductor_pr_merge`, which refuses a worker session mechanically. **A worker is never authorised to merge**, whoever else holds the authority, so PRs land one at a time with a freshness re-check; two workers merging concurrently is how agent PRs clobber each other. Who *may* merge is the [`authority`](#configuration) answer, and it is never the worker. The verb refusal is mechanical
|
|
2031
|
+
| Open a PR with `conductor_pr_create`, and poll CI to a verdict with `conductor_pr_status`. | Run `gh pr merge` — or reach `conductor_pr_merge`, which refuses a worker session mechanically. **A worker is never authorised to merge**, whoever else holds the authority, so PRs land one at a time with a freshness re-check; two workers merging concurrently is how agent PRs clobber each other. Who *may* merge is the [`authority`](#configuration) answer, and it is never the worker. The verb refusal is mechanical, and so is the channel: each one is bound to the pid the daemon spawned, so reaching for the orchestrator's socket is refused rather than honoured (see [The transport](#the-transport)). Shelling out to `gh` remains a prohibition, not an impossibility — a session shares the daemon's credentials. |
|
|
2032
2032
|
| Escalate: ambiguity, a cross-repo contract, a needed credential, a product or data-migration decision, a blocking existing test, CI red twice, or most of the wall-clock budget burned. | Cut a release, push a tag, publish to npm, edit a deployment pin, deploy, or touch infrastructure or secrets. `conductor_release` and `conductor_label` refuse a worker whatever `releasePolicy` says, because the check compares the caller against the configured holder rather than ruling one value out. The in-session tripwire still blocks recognised release/deploy tool calls early and audits the attempt, but it is [defence in depth](#the-mediated-verbs-126), not the gate. |
|
|
2033
2033
|
|
|
2034
2034
|
The worker ends with a seven-line evidence report (issue, PR, observed head SHA,
|
|
@@ -2149,10 +2149,30 @@ every release shape under the most permissive config there is.
|
|
|
2149
2149
|
### The transport
|
|
2150
2150
|
|
|
2151
2151
|
Identity is never an argument. `project`, `run`, `issue` and the caller's role
|
|
2152
|
-
come from **which socket the call arrived on
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2152
|
+
come from **which socket the call arrived on**, and a request carrying any of
|
|
2153
|
+
those field names is refused outright, named. So a worker on run X cannot *ask*
|
|
2154
|
+
to merge run Y's PR — on its own channel that request is unexpressible.
|
|
2155
|
+
|
|
2156
|
+
**Each channel is bound to one process, because the modes cannot tell sessions
|
|
2157
|
+
apart.** Every session runs as the daemon's own uid, so it matches the *owner*
|
|
2158
|
+
class here: it can list this directory and connect to any socket in it, including
|
|
2159
|
+
the orchestrator's. Authorisation and the ledger both read the role from the
|
|
2160
|
+
channel, so a worker doing that would have been authorised as the orchestrator
|
|
2161
|
+
(under `authority.merge: "orchestrator"`) *and recorded as* the orchestrator. No
|
|
2162
|
+
file mode closes that — the owner bits belong to the uid the session already has.
|
|
2163
|
+
|
|
2164
|
+
So the daemon binds each channel to the **pid it spawned for that session**, and
|
|
2165
|
+
refuses a connection from anything else without answering it, logged the way an
|
|
2166
|
+
impersonation is. Until a channel is bound it refuses everything, because the
|
|
2167
|
+
socket necessarily exists before the child that connects to it. The kernel
|
|
2168
|
+
supplies the pid: `SO_PEERCRED` on Linux, `LOCAL_PEERPID` on macOS. A host where
|
|
2169
|
+
neither can be asked refuses every connection and says so at startup rather than
|
|
2170
|
+
falling back to the uid, which would be no check at all.
|
|
2171
|
+
|
|
2172
|
+
The residual is narrow, real, and worth stating: one uid can `ptrace` and signal
|
|
2173
|
+
its siblings, so a determined session can still interfere with the process that
|
|
2174
|
+
*is* bound. That is a far higher bar than connecting to a socket, and closing it
|
|
2175
|
+
needs separate OS principals.
|
|
2156
2176
|
|
|
2157
2177
|
```
|
|
2158
2178
|
<state dir>/verbs/ daemon-owned, mode 0711
|
|
@@ -2298,9 +2318,12 @@ Known and deliberate in this version:
|
|
|
2298
2318
|
- **Workers stop at green PRs.** They are never authorised to merge, release or
|
|
2299
2319
|
deploy: those actions default to a human, and while setup may grant either to
|
|
2300
2320
|
the orchestrator, `authority` never grants them to a worker or the dispatch
|
|
2301
|
-
daemon. The verbs refuse a worker mechanically
|
|
2302
|
-
|
|
2303
|
-
|
|
2321
|
+
daemon. The verbs refuse a worker mechanically, and a worker reaching for
|
|
2322
|
+
another session's channel is refused too — each channel is bound to the pid the
|
|
2323
|
+
daemon spawned for it. What remains a prohibition rather than a gate is shelling
|
|
2324
|
+
out to `gh` directly: sessions inherit the daemon's credentials. That shows up as
|
|
2325
|
+
a mutation with no matching ledger entry, which is a mismatch an operator can
|
|
2326
|
+
find.
|
|
2304
2327
|
- **The worker gate is partial, and the orchestrator has none.** A worker's
|
|
2305
2328
|
structured `write` / `edit` / `read` / `grep` / `glob` calls are gated to its
|
|
2306
2329
|
worktree by an inline harness extension; `bash` is not, so a shell one-liner
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omp-conductor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "A 24/7 dispatcher that takes ready GitHub issues to green, mergeable PRs using omp coding sessions, with tiered escalation first to an orchestrator session and then to a human.",
|
package/src/daemon.ts
CHANGED
|
@@ -976,6 +976,14 @@ async function handleIssue(d: Deps, r: Routed, attempt: number): Promise<void> {
|
|
|
976
976
|
// a child process of the daemon reaches it directly.
|
|
977
977
|
socketPath: join(sessionDir, "ipc.sock"),
|
|
978
978
|
verbSocketPath: verbListener.path,
|
|
979
|
+
// Names this run's channel the instant the child exists. Until this
|
|
980
|
+
// fires the channel refuses every connection, because a session that
|
|
981
|
+
// reached it first would be one nobody had identified — and every
|
|
982
|
+
// session shares this daemon's uid, so the socket alone cannot tell
|
|
983
|
+
// them apart (#163).
|
|
984
|
+
onSpawn: (pid) => {
|
|
985
|
+
verbListener?.bindPid(pid);
|
|
986
|
+
},
|
|
979
987
|
onChildLog: (line) => {
|
|
980
988
|
log(`#${issue} ${line}`);
|
|
981
989
|
},
|
|
@@ -3045,10 +3053,14 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3045
3053
|
const orchCwd = join(orchTreeRoot, "orchestrator");
|
|
3046
3054
|
mkdirSync(orchCwd, { recursive: true });
|
|
3047
3055
|
// A third socket, distinct from every run's, in the same daemon-owned
|
|
3048
|
-
// 0711 parent.
|
|
3049
|
-
//
|
|
3050
|
-
//
|
|
3051
|
-
//
|
|
3056
|
+
// 0711 parent. It makes the orchestrator's authority a property of the
|
|
3057
|
+
// channel rather than of a payload: no argument list can move a worker's
|
|
3058
|
+
// call onto this one (#126).
|
|
3059
|
+
//
|
|
3060
|
+
// It does NOT authenticate the session. Sessions share this daemon's uid,
|
|
3061
|
+
// so one could list this directory and connect here, and the ledger would
|
|
3062
|
+
// record the orchestrator's role because that is the channel's. See
|
|
3063
|
+
// conductor#163 for the pid binding that would close it.
|
|
3052
3064
|
orchestratorVerbs = await listenVerbChannel(
|
|
3053
3065
|
verbDeps({ project, store, tracker, verbActions }),
|
|
3054
3066
|
{
|
|
@@ -3065,6 +3077,12 @@ export async function runDaemon(o: DaemonOpts = {}): Promise<void> {
|
|
|
3065
3077
|
releaseGrants,
|
|
3066
3078
|
socketPath: join(orchCwd, "ipc.sock"),
|
|
3067
3079
|
verbSocketPath: orchestratorVerbs.path,
|
|
3080
|
+
// The orchestrator's channel is the one that matters most: it is the
|
|
3081
|
+
// session authorised to merge, so an unbound channel here is a worker's
|
|
3082
|
+
// route to that authority (#163).
|
|
3083
|
+
onSpawn: (pid) => {
|
|
3084
|
+
orchestratorVerbs?.bindPid(pid);
|
|
3085
|
+
},
|
|
3068
3086
|
onChildLog: (line) => {
|
|
3069
3087
|
log(`orchestrator ${line}`);
|
|
3070
3088
|
},
|
package/src/omp.ts
CHANGED
|
@@ -388,6 +388,14 @@ export interface CreateSessionOptions {
|
|
|
388
388
|
* session plumbing an attack surface for verbs.
|
|
389
389
|
*/
|
|
390
390
|
verbSocketPath?: string;
|
|
391
|
+
/**
|
|
392
|
+
* The child's pid, the instant it exists.
|
|
393
|
+
*
|
|
394
|
+
* Called synchronously after `Bun.spawn` and before the child could have
|
|
395
|
+
* connected to anything, because that ordering is the point: the daemon binds
|
|
396
|
+
* its verb channel to this pid, and a channel accepts nothing until it is bound.
|
|
397
|
+
*/
|
|
398
|
+
onSpawn?: (pid: number) => void;
|
|
391
399
|
/** Child stderr, line by line. Defaults to the process's own stderr. */
|
|
392
400
|
onChildLog?: (line: string) => void;
|
|
393
401
|
startupTimeoutMs?: number;
|
|
@@ -483,6 +491,10 @@ export async function createSession(opts: CreateSessionOptions): Promise<AgentSe
|
|
|
483
491
|
stderr: "pipe",
|
|
484
492
|
});
|
|
485
493
|
|
|
494
|
+
// Synchronously, before a single await: the child cannot have connected yet, so
|
|
495
|
+
// the caller's channel is bound before it can be reached.
|
|
496
|
+
if (child.pid !== undefined) opts.onSpawn?.(child.pid);
|
|
497
|
+
|
|
486
498
|
let stderrTail = "";
|
|
487
499
|
const drain = async (stream: ReadableStream<Uint8Array> | undefined, prefix: string): Promise<void> => {
|
|
488
500
|
if (stream === undefined) return;
|
package/src/orchestrator.ts
CHANGED
|
@@ -47,6 +47,7 @@ export type CreateSessionFn = (opts: {
|
|
|
47
47
|
role: SessionRole;
|
|
48
48
|
releaseGrants?: ResolvedGrants;
|
|
49
49
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
50
|
+
onSpawn?: (pid: number) => void;
|
|
50
51
|
socketPath?: string;
|
|
51
52
|
verbSocketPath?: string;
|
|
52
53
|
onChildLog?: (line: string) => void;
|
|
@@ -84,6 +85,8 @@ export interface OrchestratorOpts {
|
|
|
84
85
|
model?: string;
|
|
85
86
|
releaseGrants?: ResolvedGrants;
|
|
86
87
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
88
|
+
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
89
|
+
onSpawn?: (pid: number) => void;
|
|
87
90
|
/** Control socket for the session child, beside its own working directory. */
|
|
88
91
|
socketPath?: string;
|
|
89
92
|
/**
|
|
@@ -180,6 +183,7 @@ export async function startOrchestrator(o: OrchestratorOpts): Promise<Orchestrat
|
|
|
180
183
|
role: "orchestrator",
|
|
181
184
|
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
182
185
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
186
|
+
...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
|
|
183
187
|
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
184
188
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
185
189
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|
package/src/verbs/server.ts
CHANGED
|
@@ -10,10 +10,24 @@
|
|
|
10
10
|
* Four rules shape the order of the checks, and each is here because prose
|
|
11
11
|
* alone did not hold it on `main`:
|
|
12
12
|
*
|
|
13
|
-
* - **Identity is the channel's
|
|
14
|
-
* {@link VerbChannel} — which
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* - **Identity is the channel's, and the channel is bound to one process.**
|
|
14
|
+
* `project`, `run`, `issue` and role come from {@link VerbChannel} — which
|
|
15
|
+
* socket the call arrived on. No payload can name them, so a *request* to act
|
|
16
|
+
* as another run is unexpressible.
|
|
17
|
+
*
|
|
18
|
+
* That alone was never authentication: every session runs as the daemon's own
|
|
19
|
+
* uid, so any of them could list the socket directory and connect to another
|
|
20
|
+
* channel — including the orchestrator's, which holds merge under
|
|
21
|
+
* `authority.merge: "orchestrator"` — and the ledger would have recorded the
|
|
22
|
+
* channel's role, attributing it to the orchestrator. A file mode cannot fix
|
|
23
|
+
* that, because the owner bits belong to the uid the session already has.
|
|
24
|
+
*
|
|
25
|
+
* So each channel is **bound to the pid the daemon spawned for it**, and a
|
|
26
|
+
* connection from any other process is refused unanswered and logged. Until it
|
|
27
|
+
* is bound it refuses everything, because the socket necessarily exists before
|
|
28
|
+
* the child does. The residual is narrow and real: one uid can still `ptrace`
|
|
29
|
+
* or signal its siblings, so a determined session can interfere with the
|
|
30
|
+
* process that *is* bound — a much higher bar than connecting to a socket.
|
|
17
31
|
* - **Compared against the configured holder, never against one forbidden
|
|
18
32
|
* value.** `authority` has exactly two holders, so `!== "human"` would let a
|
|
19
33
|
* worker release. Merge and release both ask "is the caller the holder".
|
|
@@ -53,6 +67,7 @@ import {
|
|
|
53
67
|
unlinkStaleSocket,
|
|
54
68
|
validateSocketPath,
|
|
55
69
|
type PeerReader,
|
|
70
|
+
type PeerVerdict,
|
|
56
71
|
type SocketOwnership,
|
|
57
72
|
} from "./socket.ts";
|
|
58
73
|
|
|
@@ -915,6 +930,17 @@ async function prStatusVerb(
|
|
|
915
930
|
export interface VerbListener {
|
|
916
931
|
path: string;
|
|
917
932
|
ownership: SocketOwnership;
|
|
933
|
+
/**
|
|
934
|
+
* Name the process this channel belongs to.
|
|
935
|
+
*
|
|
936
|
+
* Called with the child's pid the moment the daemon has spawned it, and the
|
|
937
|
+
* channel refuses every connection until it is: the socket has to exist before
|
|
938
|
+
* the child can connect to it, so there is a window between `listen` and the
|
|
939
|
+
* spawn, and a session that connected in that window would be one nobody had
|
|
940
|
+
* identified. `Bun.spawn` returns the pid synchronously, so the window closes
|
|
941
|
+
* before the child has finished booting.
|
|
942
|
+
*/
|
|
943
|
+
bindPid(pid: number): void;
|
|
918
944
|
close(): Promise<void>;
|
|
919
945
|
}
|
|
920
946
|
|
|
@@ -964,8 +990,13 @@ export async function listenVerbChannel(
|
|
|
964
990
|
// here and a race in a world-writable directory.
|
|
965
991
|
unlinkStaleSocket(channel.path);
|
|
966
992
|
|
|
993
|
+
// Unbound until the daemon names the child. Read through a closure rather than
|
|
994
|
+
// captured by value so the connection handler sees the binding that exists when
|
|
995
|
+
// a connection arrives, not the one that existed when the server was created.
|
|
996
|
+
let boundPid: number | undefined;
|
|
997
|
+
|
|
967
998
|
const server: Server = createServer((socket) => {
|
|
968
|
-
handleConnection(deps, channel, socket, opts.peerReader);
|
|
999
|
+
handleConnection(deps, channel, socket, opts.peerReader, () => boundPid);
|
|
969
1000
|
});
|
|
970
1001
|
server.on("error", (err) => {
|
|
971
1002
|
deps.log(`verb socket ${channel.path} errored: ${err.message}`);
|
|
@@ -985,6 +1016,9 @@ export async function listenVerbChannel(
|
|
|
985
1016
|
return {
|
|
986
1017
|
path: channel.path,
|
|
987
1018
|
ownership,
|
|
1019
|
+
bindPid: (pid) => {
|
|
1020
|
+
boundPid = pid;
|
|
1021
|
+
},
|
|
988
1022
|
close: async () => {
|
|
989
1023
|
await new Promise<void>((resolve) => {
|
|
990
1024
|
server.close(() => {
|
|
@@ -1001,10 +1035,29 @@ function handleConnection(
|
|
|
1001
1035
|
channel: VerbChannel,
|
|
1002
1036
|
socket: Socket,
|
|
1003
1037
|
peerReader: PeerReader | undefined,
|
|
1038
|
+
boundPid: () => number | undefined,
|
|
1004
1039
|
): void {
|
|
1005
1040
|
const fd = socketFd(socket);
|
|
1006
1041
|
const peer = fd === undefined || peerReader === undefined ? undefined : peerReader(fd);
|
|
1007
|
-
const
|
|
1042
|
+
const expectedPid = boundPid();
|
|
1043
|
+
// Refused until the daemon has named the process this channel belongs to.
|
|
1044
|
+
//
|
|
1045
|
+
// The socket must exist before the child can connect to it, so there is a real
|
|
1046
|
+
// window between `listen` and the spawn — and any session already running shares
|
|
1047
|
+
// this daemon's uid, so one of them could reach a freshly bound orchestrator
|
|
1048
|
+
// channel in that window and be authorised as the orchestrator. Nothing about
|
|
1049
|
+
// the socket distinguishes them; only the pid does (#163).
|
|
1050
|
+
const verdict: PeerVerdict =
|
|
1051
|
+
expectedPid === undefined
|
|
1052
|
+
? {
|
|
1053
|
+
ok: false,
|
|
1054
|
+
peerUid: peer?.uid ?? -1,
|
|
1055
|
+
expectedUid: process.getuid?.() ?? 0,
|
|
1056
|
+
detail:
|
|
1057
|
+
"this channel has not been bound to a process yet, so the caller cannot be identified — " +
|
|
1058
|
+
"every session on this host shares the daemon's uid",
|
|
1059
|
+
}
|
|
1060
|
+
: peerVerdict({ uid: process.getuid?.() ?? 0, pid: expectedPid }, peer);
|
|
1008
1061
|
if (!verdict.ok) {
|
|
1009
1062
|
// Not a client error, and deliberately not answered: a caller who is not
|
|
1010
1063
|
// who the socket was allocated to gets no reply to calibrate against.
|
package/src/verbs/socket.ts
CHANGED
|
@@ -236,6 +236,18 @@ export function secureBoundSocket(
|
|
|
236
236
|
export interface PeerCredentials {
|
|
237
237
|
uid: number;
|
|
238
238
|
gid: number;
|
|
239
|
+
/**
|
|
240
|
+
* The connecting process, as the kernel reports it.
|
|
241
|
+
*
|
|
242
|
+
* The uid alone stopped meaning anything the moment sessions began sharing the
|
|
243
|
+
* daemon's account: every session satisfies it. The pid is what distinguishes
|
|
244
|
+
* them, because the daemon spawned each one and knows which is which.
|
|
245
|
+
*
|
|
246
|
+
* `undefined` only when the platform call failed. It is not optional in the
|
|
247
|
+
* sense of "nice to have" — {@link peerVerdict} refuses a channel whose pid it
|
|
248
|
+
* expected and could not read.
|
|
249
|
+
*/
|
|
250
|
+
pid?: number;
|
|
239
251
|
}
|
|
240
252
|
|
|
241
253
|
/**
|
|
@@ -243,9 +255,9 @@ export interface PeerCredentials {
|
|
|
243
255
|
* `undefined` where the platform (or this runtime's socket object) does not
|
|
244
256
|
* expose them.
|
|
245
257
|
*
|
|
246
|
-
* `
|
|
247
|
-
* which is the point: the answer cannot be forged by the peer,
|
|
248
|
-
* the peer could put in a payload.
|
|
258
|
+
* `SO_PEERCRED` on Linux, `getpeereid` plus `LOCAL_PEERPID` on darwin — all asked
|
|
259
|
+
* of the kernel, which is the point: the answer cannot be forged by the peer,
|
|
260
|
+
* unlike anything the peer could put in a payload.
|
|
249
261
|
*/
|
|
250
262
|
export type PeerReader = (fd: number) => PeerCredentials | undefined;
|
|
251
263
|
|
|
@@ -258,6 +270,10 @@ interface LibcSymbols {
|
|
|
258
270
|
const SOL_SOCKET_LINUX = 1;
|
|
259
271
|
const SO_PEERCRED_LINUX = 17;
|
|
260
272
|
|
|
273
|
+
/** `SOL_LOCAL` / `LOCAL_PEERPID` from darwin's `sys/un.h`. Also ABI constants. */
|
|
274
|
+
const SOL_LOCAL_DARWIN = 0;
|
|
275
|
+
const LOCAL_PEERPID_DARWIN = 0x002;
|
|
276
|
+
|
|
261
277
|
/**
|
|
262
278
|
* The peer reader for this host, or `undefined` when there is none.
|
|
263
279
|
*
|
|
@@ -271,7 +287,14 @@ const SO_PEERCRED_LINUX = 17;
|
|
|
271
287
|
export function peerCredentialReader(): PeerReader | undefined {
|
|
272
288
|
const declarations: Record<string, { args: FFIType[]; returns: FFIType }> =
|
|
273
289
|
process.platform === "darwin"
|
|
274
|
-
? {
|
|
290
|
+
? {
|
|
291
|
+
getpeereid: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
292
|
+
// For LOCAL_PEERPID: `getpeereid` answers uid and gid, and nothing else.
|
|
293
|
+
getsockopt: {
|
|
294
|
+
args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr],
|
|
295
|
+
returns: FFIType.i32,
|
|
296
|
+
},
|
|
297
|
+
}
|
|
275
298
|
: {
|
|
276
299
|
getsockopt: {
|
|
277
300
|
args: [FFIType.i32, FFIType.i32, FFIType.i32, FFIType.ptr, FFIType.ptr],
|
|
@@ -302,12 +325,21 @@ export function peerCredentialReader(): PeerReader | undefined {
|
|
|
302
325
|
|
|
303
326
|
if (process.platform === "darwin") {
|
|
304
327
|
const getpeereid = symbols.getpeereid;
|
|
328
|
+
const getsockoptDarwin = symbols.getsockopt;
|
|
305
329
|
if (getpeereid === undefined) return undefined;
|
|
306
330
|
return (fd) => {
|
|
307
331
|
const uid = new Uint32Array(1);
|
|
308
332
|
const gid = new Uint32Array(1);
|
|
309
333
|
if (getpeereid(fd, ptr(uid), ptr(gid)) !== 0) return undefined;
|
|
310
|
-
|
|
334
|
+
let pid: number | undefined;
|
|
335
|
+
if (getsockoptDarwin !== undefined) {
|
|
336
|
+
const out = new Uint32Array(1);
|
|
337
|
+
const len = new Uint32Array([out.byteLength]);
|
|
338
|
+
if (getsockoptDarwin(fd, SOL_LOCAL_DARWIN, LOCAL_PEERPID_DARWIN, ptr(out), ptr(len)) === 0) {
|
|
339
|
+
pid = out[0];
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { uid: uid[0] ?? -1, gid: gid[0] ?? -1, ...(pid === undefined ? {} : { pid }) };
|
|
311
343
|
};
|
|
312
344
|
}
|
|
313
345
|
|
|
@@ -320,7 +352,8 @@ export function peerCredentialReader(): PeerReader | undefined {
|
|
|
320
352
|
if (getsockopt(fd, SOL_SOCKET_LINUX, SO_PEERCRED_LINUX, ptr(cred), ptr(len)) !== 0) {
|
|
321
353
|
return undefined;
|
|
322
354
|
}
|
|
323
|
-
|
|
355
|
+
// struct ucred's first field is the pid, and it has been read all along.
|
|
356
|
+
return { uid: cred[1] ?? -1, gid: cred[2] ?? -1, ...(cred[0] === undefined ? {} : { pid: cred[0] }) };
|
|
324
357
|
};
|
|
325
358
|
}
|
|
326
359
|
|
|
@@ -339,22 +372,37 @@ export function socketFd(socket: unknown): number | undefined {
|
|
|
339
372
|
}
|
|
340
373
|
|
|
341
374
|
export type PeerVerdict =
|
|
375
|
+
| { ok: true; basis: "peer-pid"; uid: number; pid: number }
|
|
342
376
|
| { ok: true; basis: "peer-uid"; uid: number }
|
|
343
377
|
| { ok: true; basis: "socket-ownership"; why: string }
|
|
344
378
|
| { ok: false; detail: string; peerUid: number; expectedUid: number };
|
|
345
379
|
|
|
346
380
|
/**
|
|
347
|
-
* Compare the kernel-reported peer against the
|
|
381
|
+
* Compare the kernel-reported peer against the session the daemon launched.
|
|
382
|
+
*
|
|
383
|
+
* The three `ok` bases are different guarantees and are deliberately not
|
|
384
|
+
* collapsed:
|
|
385
|
+
*
|
|
386
|
+
* - `peer-pid` — the kernel says this connection comes from the exact process the
|
|
387
|
+
* daemon spawned for this channel. This is the only basis that distinguishes one
|
|
388
|
+
* session from another, because they all share the daemon's uid.
|
|
389
|
+
* - `peer-uid` — the uid matched but no pid was expected for this channel. True of
|
|
390
|
+
* a channel nothing was bound to, which in production is a bug and in a test is
|
|
391
|
+
* the case under test.
|
|
392
|
+
* - `socket-ownership` — nobody could vouch at all, and the whole guarantee is the
|
|
393
|
+
* `0600` socket under an unlistable, daemon-owned parent. Real against other
|
|
394
|
+
* accounts, and no help between sessions.
|
|
395
|
+
*
|
|
396
|
+
* A caller that printed "verified" for all three would be claiming something it
|
|
397
|
+
* never checked, which is exactly how the channel came to be described as
|
|
398
|
+
* authentication when it was only ever context.
|
|
348
399
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* socket under an unlistable, daemon-owned, non-writable parent — which is
|
|
353
|
-
* real, but it does not distinguish two runs sharing one uid. A caller that
|
|
354
|
-
* printed "verified" for both would be claiming something it never checked.
|
|
400
|
+
* **Fail closed on doubt.** If a pid was expected and the kernel would not answer,
|
|
401
|
+
* that is a refusal: on a host where the check normally works, "could not ask"
|
|
402
|
+
* arriving on the one connection that matters is not a reason to allow it.
|
|
355
403
|
*/
|
|
356
404
|
export function peerVerdict(
|
|
357
|
-
expected: { uid: number } | undefined,
|
|
405
|
+
expected: { uid: number; pid?: number } | undefined,
|
|
358
406
|
peer: PeerCredentials | undefined,
|
|
359
407
|
): PeerVerdict {
|
|
360
408
|
if (expected === undefined) {
|
|
@@ -379,6 +427,29 @@ export function peerVerdict(
|
|
|
379
427
|
detail: `uid ${peer.uid} connected to a socket allocated to uid ${expected.uid}`,
|
|
380
428
|
};
|
|
381
429
|
}
|
|
430
|
+
if (expected.pid !== undefined) {
|
|
431
|
+
if (peer.pid === undefined) {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
peerUid: peer.uid,
|
|
435
|
+
expectedUid: expected.uid,
|
|
436
|
+
detail:
|
|
437
|
+
`this channel belongs to pid ${expected.pid} and the kernel would not report the caller's pid, ` +
|
|
438
|
+
`so the session could not be identified — every session shares this uid`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
if (peer.pid !== expected.pid) {
|
|
442
|
+
return {
|
|
443
|
+
ok: false,
|
|
444
|
+
peerUid: peer.uid,
|
|
445
|
+
expectedUid: expected.uid,
|
|
446
|
+
detail:
|
|
447
|
+
`pid ${peer.pid} connected to a channel belonging to pid ${expected.pid} — same uid, ` +
|
|
448
|
+
`different session, so this is another session assuming this one's authority`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return { ok: true, basis: "peer-pid", uid: peer.uid, pid: peer.pid };
|
|
452
|
+
}
|
|
382
453
|
return { ok: true, basis: "peer-uid", uid: peer.uid };
|
|
383
454
|
}
|
|
384
455
|
|
|
@@ -386,10 +457,10 @@ export function peerVerdict(
|
|
|
386
457
|
export function transportBanner(dir: string, peerReader: PeerReader | undefined): string {
|
|
387
458
|
const peers =
|
|
388
459
|
peerReader === undefined
|
|
389
|
-
? `no peer-credential call on ${process.platform}`
|
|
460
|
+
? `no peer-credential call on ${process.platform} — channels REFUSE every connection`
|
|
390
461
|
: process.platform === "darwin"
|
|
391
|
-
? "
|
|
392
|
-
: "
|
|
462
|
+
? "caller pid+uid asserted with getpeereid and LOCAL_PEERPID; each channel bound to the session the daemon launched"
|
|
463
|
+
: "caller pid+uid asserted with SO_PEERCRED; each channel bound to the session the daemon launched";
|
|
393
464
|
return (
|
|
394
465
|
`verb sockets in ${dir} (mode ${VERB_DIR_MODE.toString(8)}); ` +
|
|
395
466
|
`each socket 0600 under the daemon's own uid; ${peers}`
|
package/src/worker.ts
CHANGED
|
@@ -53,6 +53,8 @@ export interface WorkerOpts {
|
|
|
53
53
|
releaseGrants?: ResolvedGrants;
|
|
54
54
|
/** Durable audit sink for rejected release/deploy calls. */
|
|
55
55
|
onReleaseBlocked?: (shape: ReleaseShape) => void;
|
|
56
|
+
/** The child's pid, the instant it exists. See {@link VerbListener.bindPid}. */
|
|
57
|
+
onSpawn?: (pid: number) => void;
|
|
56
58
|
/** Control socket for that child, beside the run's own session directory. */
|
|
57
59
|
socketPath?: string;
|
|
58
60
|
/**
|
|
@@ -195,6 +197,7 @@ export async function runWorker(
|
|
|
195
197
|
role: "worker",
|
|
196
198
|
...(o.releaseGrants === undefined ? {} : { releaseGrants: o.releaseGrants }),
|
|
197
199
|
...(o.onReleaseBlocked === undefined ? {} : { onReleaseBlocked: o.onReleaseBlocked }),
|
|
200
|
+
...(o.onSpawn === undefined ? {} : { onSpawn: o.onSpawn }),
|
|
198
201
|
...(o.socketPath === undefined ? {} : { socketPath: o.socketPath }),
|
|
199
202
|
...(o.verbSocketPath === undefined ? {} : { verbSocketPath: o.verbSocketPath }),
|
|
200
203
|
...(o.onChildLog === undefined ? {} : { onChildLog: o.onChildLog }),
|