pi-crew 0.11.44 → 0.11.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.44",
3
+ "version": "0.11.45",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -165,12 +165,21 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => un
165
165
  // pi-crew:rpc:live-control, and the realtime pi-crew:live-control channel) are
166
166
  // accessible to any extension on the shared event bus. Mitigations: rate
167
167
  // limiting, explicit intent, operation allowlist, cwd containment validation,
168
- // and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9): without a
169
- // valid HMAC signature every RPC surface is read-only — the request/response
170
- // channels enforce this via RPC_ALLOWED_OPERATIONS, and the realtime
171
- // pi-crew:live-control channel rejects any operation outside
172
- // LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS (currently ALL of them, since
173
- // steer/stop/resume/follow-up are mutating) with a warn log and no publish.
168
+ // and HMAC origin signing (see rpc-hmac.ts). Trust model (R7-9) — what a
169
+ // publisher WITHOUT a valid HMAC signature may do:
170
+ // - pi-crew:rpc:run is the ONLY mutating channel that accepts unsigned
171
+ // requests. This is deliberate (SEC-03, test-pinned backward-compat):
172
+ // each call is rate-limited (RPC_RATE_LIMIT_MAX requests per
173
+ // RPC_RATE_LIMIT_WINDOW_MS — 5/60s), the payload is filtered to the
174
+ // ALLOWED_RPC_RUN_KEYS allowlist (unknown keys are stripped before
175
+ // handleTeamTool ever sees them), and config.cwd must pass containment
176
+ // validation (inside the project directory).
177
+ // - The remaining request/response channels are read-only for unsigned
178
+ // publishers, enforced via RPC_ALLOWED_OPERATIONS.
179
+ // - The realtime pi-crew:live-control channel rejects ALL unsigned
180
+ // operations — LIVE_CONTROL_UNAUTHENTICATED_OPERATIONS is the empty set
181
+ // (steer/stop/resume/follow-up are all mutating) — with a warn log and no
182
+ // publish.
174
183
 
175
184
  export function registerPiCrewRpc(
176
185
  events: EventBusLike | undefined,
@@ -90,6 +90,7 @@ import {
90
90
  } from "./protocol/request-parsers.ts";
91
91
  import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
92
92
  import { handleWaitResolve } from "./protocol/wait-resolve.ts";
93
+ import { captureSocketIdentity, type SocketIdentity, stopServerAndRemoveSocket } from "./socket-stop.ts";
93
94
  import { pushWaitingToForegroundWaiter } from "./wait-push.ts";
94
95
  import { WaitStatusCache } from "./wait-status-cache.ts";
95
96
 
@@ -144,6 +145,10 @@ export class CrewBroker {
144
145
  private readonly taskTokens = new Map<string, BrokerToken>();
145
146
  private server: net.Server | null = null;
146
147
  private resolvedSocketPath: string | null = null;
148
+ /** [3] stop-unlink race: dev+ino of the leaf we created, captured right
149
+ * after listen() succeeded. stop() removes the path only while this
150
+ * still matches (a rebound leaf is a new inode); see socket-stop.ts. */
151
+ private socketIdentity: SocketIdentity | null = null;
147
152
  private stopped = false;
148
153
  private startingPromise: Promise<void> | null = null;
149
154
  private readonly connections = new Set<ServerConnection>();
@@ -350,6 +355,13 @@ export class CrewBroker {
350
355
  }
351
356
  });
352
357
 
358
+ // 4.5 [3] stop-unlink race: pin the fresh leaf's identity so stop()
359
+ // can prove it still owns the path before removing it (win32
360
+ // named pipes have no fs leaf — nothing to pin).
361
+ if (process.platform !== "win32") {
362
+ this.socketIdentity = await captureSocketIdentity(sockPath);
363
+ }
364
+
353
365
  // 5. Tighten socket permissions on POSIX. Node's net module has no
354
366
  // `mode` option (unlike `fs`), so we chmod after listen() succeeds.
355
367
  if (process.platform !== "win32") {
@@ -432,43 +444,46 @@ export class CrewBroker {
432
444
  }
433
445
  this.connections.clear();
434
446
 
435
- // 2. Close the server itself.
436
- if (this.server) {
437
- await new Promise<void>((resolve) => {
438
- const srv = this.server;
447
+ // 2+4. Close the server AND remove the socket path in one
448
+ // ownership-gated step. [3] stop-unlink race: libuv unlinks the
449
+ // bind path BY NAME when the listening handle closes, so the
450
+ // unlink/shield decision must wrap the close itself — see
451
+ // broker/socket-stop.ts. We never touch any other path and
452
+ // never `process.kill` anything.
453
+ const srv = this.server;
454
+ this.server = null;
455
+ const sockPath = this.resolvedSocketPath ?? this.options.socketPath;
456
+ const closeServer = () =>
457
+ new Promise<void>((resolve) => {
439
458
  if (!srv) return resolve();
440
459
  srv.close(() => resolve());
441
460
  // If the server is not currently listening, close() resolves
442
461
  // synchronously — guard with a hard timeout for safety.
443
462
  setTimeout(() => resolve(), 250).unref();
444
463
  });
445
- this.server = null;
464
+ if (sockPath && process.platform !== "win32") {
465
+ const outcome = await stopServerAndRemoveSocket(sockPath, this.socketIdentity, closeServer);
466
+ if (outcome === "shielded-foreign" || outcome === "unshielded-foreign") {
467
+ // Path exists but is not provably ours (rebound or never bound):
468
+ // we did not remove it — any corpse is left for
469
+ // removeStaleBrokerSocket at the next start (fail-safe design).
470
+ logInternalError(
471
+ "crew-broker.stop.unlink-skipped",
472
+ new Error(`socket path not provably owned (${outcome}); left for removeStaleBrokerSocket`),
473
+ `path=${redactSecretString(sockPath)}`,
474
+ );
475
+ }
476
+ } else {
477
+ await closeServer();
446
478
  }
479
+ this.resolvedSocketPath = null;
480
+ this.socketIdentity = null;
447
481
 
448
482
  // 3. Clear the token map. This is the single point where the heap
449
483
  // state for runIds is wiped. No persistence to clean up.
450
484
  this.tokens.clear();
451
485
  // Task 10: drop the taskId → token index with it.
452
486
  this.taskTokens.clear();
453
-
454
- // 4. Unlink the recorded socket file IF we created it. We never
455
- // touch any other path. We also never `process.kill` anything.
456
- const sockPath = this.resolvedSocketPath ?? this.options.socketPath;
457
- if (sockPath && process.platform !== "win32") {
458
- try {
459
- await fsp.unlink(sockPath);
460
- } catch (err) {
461
- const code = (err as NodeJS.ErrnoException).code;
462
- if (code !== "ENOENT") {
463
- logInternalError(
464
- "crew-broker.stop.unlink-failed",
465
- err instanceof Error ? err : new Error(String(err)),
466
- `path=${redactSecretString(sockPath)}`,
467
- );
468
- }
469
- }
470
- }
471
- this.resolvedSocketPath = null;
472
487
  }
473
488
 
474
489
  // Connection lifecycle
@@ -759,15 +774,7 @@ export class CrewBroker {
759
774
  }
760
775
  }
761
776
 
762
- // Bounded identity checks. taskId must be a non-empty string.
763
- if (typeof taskId !== "string" || taskId.length === 0 || taskId.length > 256) {
764
- this.sendErrorAndClose(conn, id, "auth", "hello rejected");
765
- return;
766
- }
767
- if (typeof runId !== "string" || runId.length === 0 || runId.length > 256) {
768
- this.sendErrorAndClose(conn, id, "auth", "hello rejected");
769
- return;
770
- }
777
+ // taskId/runId/token bounds (non-empty, ≤256) are enforced upstream by isHelloParams — protocol/request-parsers.ts.
771
778
 
772
779
  // Bind connection identity and ack. Ack never includes the token.
773
780
  conn.authed = true;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * socket-stop.ts — Ownership-gated teardown of the broker's listening socket.
3
+ *
4
+ * An AF_UNIX listener has TWO unlink vectors, and both must be owned-gated:
5
+ * 1. The explicit `fs.unlink` of the recorded path (CrewBroker.stop step 4).
6
+ * 2. libuv itself: `uv__pipe_close` unlinks the server pipe's bind path BY
7
+ * NAME (no inode check) when the listening handle closes — Node's
8
+ * `server.close()` deletes whatever currently sits at the path, even a
9
+ * socket another listener re-bound there after ours was unlinked
10
+ * (empirically verified on Node 22, 2026-10-26).
11
+ *
12
+ * The broker socket path is deterministic per sessionId, so a rebound leaf
13
+ * must survive our stop(). stopServerAndRemoveSocket therefore:
14
+ * - Unlinks the leaf itself FIRST when its dev+ino match the identity
15
+ * captured at listen() time — libuv's close-unlink then finds nothing.
16
+ * - Otherwise, if ANY entry lives at the path (rebound foreign socket, or
17
+ * ownership unprovable because the capture lstat failed), renames it
18
+ * aside (shield) across closeServer() and renames it back afterwards:
19
+ * a brief ENOENT window for new connects instead of permanent deletion
20
+ * of a live foreign socket. B-LOW2: the rename-back happens ONLY while
21
+ * sockPath is still absent — if a new listener re-bound the leaf during
22
+ * the close, its leaf wins and the shield is kept (renaming back would
23
+ * clobber the fresh bind).
24
+ * A stranded shield (crash between renames, or one kept after a rebind)
25
+ * keeps its `.stopping.` suffix and is swept by the corpse sweep inside
26
+ * removeStaleBrokerSocket at the next start — it unlinks a shield ONLY
27
+ * when the pid embedded in its name is provably dead (B-LOW1).
28
+ * - Windows named pipes have no filesystem leaf: plain close only.
29
+ *
30
+ * Never throws; diagnostics flow through logInternalError with redaction.
31
+ */
32
+
33
+ import { randomBytes } from "node:crypto";
34
+ import * as fsp from "node:fs/promises";
35
+ import { logInternalError } from "../../utils/internal-error.ts";
36
+ import { redactSecretString } from "../../utils/redaction.ts";
37
+
38
+ /** dev+ino of the socket leaf created by listen() — captured immediately
39
+ * after a successful bind so stop() can prove continued ownership of the
40
+ * path before removing it. Null when the lstat failed (stop() then treats
41
+ * the leaf as NOT provably ours — the safe direction). */
42
+ export interface SocketIdentity {
43
+ dev: number;
44
+ ino: number;
45
+ }
46
+
47
+ /** Capture the identity of the freshly-bound socket leaf (POSIX only). */
48
+ export async function captureSocketIdentity(sockPath: string): Promise<SocketIdentity | null> {
49
+ try {
50
+ const st = await fsp.lstat(sockPath);
51
+ return { dev: st.dev, ino: st.ino };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export type SocketStopOutcome =
58
+ /** Our leaf (identity matched) was unlinked by us before the close. */
59
+ | "unlinked"
60
+ /** A foreign/unpinned entry existed and was shielded across the close. */
61
+ | "shielded-foreign"
62
+ /** A foreign entry existed but the shield rename failed (close then
63
+ * unlinks it by name — the pre-fix behavior; logged loudly). */
64
+ | "unshielded-foreign"
65
+ /** Nothing lived at the path (or win32: no fs leaf at all). */
66
+ | "absent";
67
+
68
+ /** Close the listening server and remove the socket path ONLY while we
69
+ * provably own it. `closeServer` must perform the net.Server close (the
70
+ * libuv by-name unlink vector) — it is called exactly once, sandwiched
71
+ * inside the ownership decision. Never throws. */
72
+ export async function stopServerAndRemoveSocket(
73
+ sockPath: string,
74
+ identity: SocketIdentity | null,
75
+ closeServer: () => Promise<void>,
76
+ ): Promise<SocketStopOutcome> {
77
+ if (process.platform === "win32") {
78
+ await closeServer();
79
+ return "absent";
80
+ }
81
+ const current = await fsp.lstat(sockPath).catch(() => null);
82
+ if (current === null) {
83
+ await closeServer();
84
+ return "absent";
85
+ }
86
+ const owned = identity !== null && current.dev === identity.dev && current.ino === identity.ino;
87
+ if (owned) {
88
+ // Remove our own leaf first so libuv's close-unlink finds nothing.
89
+ // (Microsecond TOCTOU between this unlink and the close-unlink is
90
+ // inherent to path-based APIs — accepted, same class as before.)
91
+ await fsp.unlink(sockPath).catch(() => {
92
+ /* best-effort: the leaf may already be gone */
93
+ });
94
+ await closeServer();
95
+ return "unlinked";
96
+ }
97
+ // Foreign or unprovably-owned entry: shield it across the close.
98
+ const shield = `${sockPath}.stopping.${process.pid}.${randomBytes(3).toString("hex")}`;
99
+ let shielded = false;
100
+ try {
101
+ await fsp.rename(sockPath, shield);
102
+ shielded = true;
103
+ } catch (err) {
104
+ logInternalError(
105
+ "crew-broker.stop.shield-rename-failed",
106
+ err instanceof Error ? err : new Error(String(err)),
107
+ `path=${redactSecretString(sockPath)}`,
108
+ );
109
+ }
110
+ try {
111
+ await closeServer();
112
+ } finally {
113
+ if (shielded) {
114
+ // B-LOW2: restore ONLY while the leaf is still absent. While our
115
+ // handle was closing, a new listener may have bound sockPath — a
116
+ // rename back would REPLACE its fresh leaf (rename clobbers the
117
+ // target), recreating the exact deletion bug the shield exists to
118
+ // prevent. When rebound: keep the shield (the corpse sweep at the
119
+ // next start removes it once this pid is dead) and log loudly.
120
+ let rebound = false;
121
+ try {
122
+ await fsp.lstat(sockPath);
123
+ rebound = true;
124
+ } catch {
125
+ /* still absent — safe to restore */
126
+ }
127
+ if (rebound) {
128
+ logInternalError(
129
+ "crew-broker.stop.shield-kept-rebound",
130
+ new Error(`socket path re-bound during close — keeping shield at ${shield} instead of clobbering the new listener`),
131
+ `path=${redactSecretString(sockPath)}`,
132
+ );
133
+ } else {
134
+ try {
135
+ await fsp.rename(shield, sockPath);
136
+ } catch (err) {
137
+ logInternalError(
138
+ "crew-broker.stop.shield-restore-failed",
139
+ err instanceof Error ? err : new Error(String(err)),
140
+ `path=${redactSecretString(shield)}`,
141
+ );
142
+ }
143
+ }
144
+ }
145
+ }
146
+ return shielded ? "shielded-foreign" : "unshielded-foreign";
147
+ }
@@ -57,6 +57,11 @@ const identityByPid = new Map<number, ProcIdentity>();
57
57
  // Wave 11a injection seam (same shape as zombie-scanner.ts `__test`): tests seed
58
58
  // identity snapshots and swap the group reaper without a real child process.
59
59
  let reapGroupImpl: typeof reapGroup = reapGroup;
60
+ // Wave 16 (E1): injectable /proc identity reader for killProcessPid's PID-recycle
61
+ // re-validation — tests simulate a recycled starttime without racing the OS for
62
+ // a real pid reuse. registerActiveChild deliberately keeps the REAL reader (its
63
+ // identityByPid snapshot is production /proc state, not a test fixture).
64
+ let readProcIdentityImpl: typeof readProcIdentity = readProcIdentity;
60
65
  export const __test = {
61
66
  /** Seed the exit-time identity snapshot for a pid (as registerActiveChild would). */
62
67
  seedIdentity(pid: number, identity: ProcIdentity | undefined): void {
@@ -71,6 +76,10 @@ export const __test = {
71
76
  setReapGroup(fn: typeof reapGroup | undefined): void {
72
77
  reapGroupImpl = fn ?? reapGroup;
73
78
  },
79
+ /** Replace the /proc identity reader used by killProcessPid's E1 re-validation (undefined restores the real one). */
80
+ setReadProcIdentity(fn: typeof readProcIdentity | undefined): void {
81
+ readProcIdentityImpl = fn ?? readProcIdentity;
82
+ },
74
83
  };
75
84
 
76
85
  // Periodic cleanup of dead child process entries to prevent memory leaks.
@@ -173,9 +182,24 @@ export function killProcessPid(pid: number): void {
173
182
  void owned.dispose();
174
183
  return;
175
184
  }
185
+ // E1 (fix-wave 16): baseline the pid's /proc starttime (stat field 22) while
186
+ // it is alive at kill entry; the hard-kill timer below re-reads it and
187
+ // refuses to signal a CHANGED starttime — the original process exited during
188
+ // the grace window and an unrelated process recycled the pid. Effectively
189
+ // Linux-only: Windows has no /proc (readProcIdentity → undefined ⇒ no
190
+ // baseline ⇒ the timer keeps the pre-wave unconditional behavior — see the
191
+ // win32 branch note below), and /proc-less POSIX (macOS) degrades the same
192
+ // way. Deliberately NOT the registration-snapshot pattern (identityByPid):
193
+ // external pid-only callers — live-session agents, cancel paths on stale
194
+ // pids — have no registration to consult.
195
+ const baselineStarttime = process.platform === "win32" ? undefined : readProcIdentityImpl(pid)?.starttime;
176
196
  try {
177
197
  if (process.platform === "win32") {
178
198
  // 3.8: Windows path uses taskkill /T /F (force kill the entire tree).
199
+ // E1 note: Windows has no /proc, so no starttime baseline/re-validation
200
+ // is possible here — behavior stays exactly as before. taskkill /T /F on
201
+ // a recycled pid fails harmlessly ("process not found"), so the residual
202
+ // recycle window is not actionable on this platform.
179
203
  // taskkill itself can silently fail (PID gone, permission denied, etc.)
180
204
  // so verify after 2s and log a warning if the process is still alive.
181
205
  spawnTaskkillSafe(pid);
@@ -213,6 +237,26 @@ export function killProcessPid(pid: number): void {
213
237
  }
214
238
  clearHardKillTimer(pid);
215
239
  const hardKillTimer = setTimeout(() => {
240
+ // E1: PID-recycle re-validation immediately before the hard kill —
241
+ // guards BOTH the group kill (-pid) below and the absolute-pid fallback
242
+ // inside its catch. Skip ONLY on a proven mismatch (baseline known AND
243
+ // current known AND different): an unreadable current value means the pid
244
+ // is already gone (the kill below reports ESRCH exactly as before) or
245
+ // /proc is unavailable on this platform (there was never a baseline to
246
+ // compare against).
247
+ const currentStarttime = readProcIdentityImpl(pid)?.starttime;
248
+ if (baselineStarttime !== undefined && currentStarttime !== undefined && currentStarttime !== baselineStarttime) {
249
+ logInternalError(
250
+ "child-pi.sigkill-pid-recycled",
251
+ new Error(
252
+ `starttime changed between SIGTERM and the hard-kill timer (${baselineStarttime} → ${currentStarttime}) — pid was recycled, skipping hard kill of the unrelated new occupant`,
253
+ ),
254
+ `pid=${pid}`,
255
+ "warn",
256
+ );
257
+ childHardKillTimers.delete(pid);
258
+ return;
259
+ }
216
260
  try {
217
261
  process.kill(-pid, "SIGKILL");
218
262
  } catch (error) {
@@ -36,7 +36,11 @@
36
36
  * Anthropic usage events; per-worker in-flight cap; revoke(workerKey) /
37
37
  * revokeAll() kill-switch (revoked token → 401 on the NEXT request; in-flight
38
38
  * requests are allowed to drain — killing sockets mid-stream corrupts
39
- * transcripts without adding security, the token is already dead).
39
+ * transcripts without adding security, the token is already dead). The token
40
+ * map is keyed by the SHA-256 digest of each token, never the raw secret
41
+ * (constant-time discipline, same pattern as crew-broker-tokens.ts); after
42
+ * registerInferenceWorker hands the raw token to the caller, the broker
43
+ * retains only its digest.
40
44
  *
41
45
  * Honest boundary: the broker protects the REUSABLE CREDENTIAL channel only.
42
46
  * A worker can still read other host files (~/.ssh, ~/.aws, ~/.pi/agent/
@@ -88,7 +92,10 @@ const STRIP_RESPONSE_HEADERS = new Set(["content-encoding", "content-length", "t
88
92
 
89
93
  interface WorkerEntry {
90
94
  workerKey: string;
91
- token: string;
95
+ /** SHA-256 hex digest of the worker token — the RAW secret is handed to
96
+ * the caller by registerInferenceWorker and never retained here
97
+ * (constant-time discipline, fix-wave TASK [5]). */
98
+ tokenDigest: string;
92
99
  revoked: boolean;
93
100
  requests: number;
94
101
  inputTokens: number;
@@ -97,6 +104,15 @@ interface WorkerEntry {
97
104
  maxTokens: number;
98
105
  }
99
106
 
107
+ /** Map key for the per-worker token registry: the SHA-256 hex digest of the
108
+ * token, NEVER the raw secret — mirrors BrokerTokenRegistry.hashToken
109
+ * (runtime/broker/crew-broker-tokens.ts). Hashed locally with the identical
110
+ * implementation instead of importing the registry class, so this module's
111
+ * static import graph is untouched. */
112
+ function tokenKey(token: string): string {
113
+ return crypto.createHash("sha256").update(token, "utf8").digest("hex");
114
+ }
115
+
100
116
  export interface InferenceBrokerSnapshot {
101
117
  active: boolean;
102
118
  baseUrl: string;
@@ -121,7 +137,7 @@ interface BrokerState {
121
137
  server: http.Server;
122
138
  port: number;
123
139
  routes: Map<string, ProviderRoute>; // by path prefix ("/anthropic", "/openai")
124
- workers: Map<string, WorkerEntry>; // by token
140
+ workers: Map<string, WorkerEntry>; // by SHA-256 token digest (never the raw secret)
125
141
  byKey: Map<string, WorkerEntry>; // by workerKey
126
142
  scopedAgentDir: string;
127
143
  }
@@ -381,7 +397,7 @@ async function handleRequest(
381
397
  }
382
398
  // (1) Auth — fail-closed, generic errors (never echo tokens).
383
399
  const token = extractToken(req);
384
- const worker = token ? ctx.workers.get(token) : undefined;
400
+ const worker = token ? ctx.workers.get(tokenKey(token)) : undefined;
385
401
  if (!worker || worker.revoked) {
386
402
  deny(res, 401, "invalid or revoked inference token");
387
403
  return;
@@ -535,13 +551,22 @@ export function registerInferenceWorker(workerKey: string): string {
535
551
  const existing = broker.byKey.get(workerKey);
536
552
  if (existing) {
537
553
  existing.revoked = true;
538
- broker.workers.delete(existing.token);
554
+ broker.workers.delete(existing.tokenDigest);
539
555
  broker.byKey.delete(workerKey);
540
556
  }
541
557
  const token = crypto.randomBytes(32).toString("hex");
542
558
  const maxTokens = getCrewEnvInt(INFERENCE_BUDGET_ENV) ?? 0;
543
- const entry: WorkerEntry = { workerKey, token, revoked: false, requests: 0, inputTokens: 0, outputTokens: 0, inFlight: 0, maxTokens };
544
- broker.workers.set(token, entry);
559
+ const entry: WorkerEntry = {
560
+ workerKey,
561
+ tokenDigest: tokenKey(token),
562
+ revoked: false,
563
+ requests: 0,
564
+ inputTokens: 0,
565
+ outputTokens: 0,
566
+ inFlight: 0,
567
+ maxTokens,
568
+ };
569
+ broker.workers.set(entry.tokenDigest, entry);
545
570
  broker.byKey.set(workerKey, entry);
546
571
  return token;
547
572
  }
@@ -574,7 +599,7 @@ export function revokeInferenceWorker(workerKey: string): boolean {
574
599
  const entry = broker.byKey.get(workerKey);
575
600
  if (!entry) return false;
576
601
  entry.revoked = true;
577
- broker.workers.delete(entry.token);
602
+ broker.workers.delete(entry.tokenDigest);
578
603
  broker.byKey.delete(workerKey);
579
604
  return true;
580
605
  }
@@ -616,3 +641,10 @@ export function stopInferenceBroker(): void {
616
641
  export function __resetInferenceBrokerForTests(): void {
617
642
  stopInferenceBroker();
618
643
  }
644
+
645
+ /** @internal TESTS ONLY — the live token-map keys. By construction these are
646
+ * SHA-256 digests (see tokenKey); the suite pins that no RAW token ever
647
+ * appears as a map key (constant-time discipline). */
648
+ export function __inferenceBrokerTokenMapKeysForTests(): string[] {
649
+ return broker ? [...broker.workers.keys()] : [];
650
+ }
@@ -24,6 +24,7 @@ import { spawnSync } from "node:child_process";
24
24
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
25
25
  import { findRepoRoot } from "../../utils/paths.ts";
26
26
  import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
27
+ import { buildGitSafeEnv, hardenGitArgs } from "../../worktree/git-hardening.ts";
27
28
 
28
29
  /**
29
30
  * Roles whose job is to EDIT project source/test files (changes that appear in
@@ -54,7 +55,15 @@ export function isGitWorkingTreeClean(cwd: string): { clean: boolean; repoRoot:
54
55
  const repoRoot = findRepoRoot(cwd);
55
56
  if (!repoRoot) return { clean: "unknown", repoRoot: undefined };
56
57
  // `git status --porcelain` → empty stdout means a clean working tree.
57
- const res = spawnSync("git", ["-C", repoRoot, "status", "--porcelain"], { encoding: "utf8", timeout: 5_000 });
58
+ // R-A5 (MED-1): previously spawnSync inherited the FULL process.env —
59
+ // API keys could leak to config-reachable helpers git spawns. Now the
60
+ // sanitized env + hardenGitArgs (cwd is the trusted manifest repo root,
61
+ // so no gitdir-pointer guard is needed here).
62
+ const res = spawnSync("git", hardenGitArgs(["-C", repoRoot, "status", "--porcelain"]), {
63
+ encoding: "utf-8",
64
+ timeout: 5_000,
65
+ env: buildGitSafeEnv(),
66
+ });
58
67
  if (res.error || res.status !== 0) return { clean: "unknown", repoRoot: undefined };
59
68
  return { clean: res.stdout.trim().length === 0, repoRoot };
60
69
  } catch {
@@ -260,7 +260,16 @@ function resolvePiCliScript(): string | undefined {
260
260
  process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@earendil-works", "pi-coding-agent") : undefined,
261
261
  process.env.APPDATA ? path.join(process.env.APPDATA, "npm", "node_modules", "@mariozechner", "pi-coding-agent") : undefined,
262
262
  path.dirname(fileURLToPath(import.meta.url)),
263
- process.cwd(),
263
+ // E2 (fix-wave 16): the process.cwd() root is deliberately REMOVED.
264
+ // findPiPackageJsonFrom matches package.json by NAME only (no version or
265
+ // signature check), so a booby-trapped working directory carrying a fake
266
+ // package.json (name=@earendil-works/pi-coding-agent + bin) would get its
267
+ // attacker binary spawned with the worker env — which carries the
268
+ // inference-broker token. Legitimate installs stay covered by the roots
269
+ // above (argv1 package root, `npm root -g`, %APPDATA%\npm, and the
270
+ // import.meta install-dir walk, which still scans every ancestor
271
+ // node_modules chain via findPiPackageJsonFrom) plus the bare-"pi" PATH
272
+ // fallback in getPiSpawnCommand.
264
273
  ].filter((entry): entry is string => Boolean(entry));
265
274
 
266
275
  for (const root of roots) {
@@ -30,6 +30,8 @@ import type {
30
30
  UsageState,
31
31
  VerificationEvidence,
32
32
  } from "../../state/types.ts";
33
+ import { logInternalError } from "../../utils/internal-error.ts";
34
+ import { buildGitSafeEnv, hardenGitArgs, worktreeTrustCheck } from "../../worktree/git-hardening.ts";
33
35
  import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync } from "../../worktree/worktree-manager.ts";
34
36
  import { appendTaskAttentionEvent } from "../attention-events.ts";
35
37
  import { extractCommandTrace } from "../command-trace.ts";
@@ -150,16 +152,48 @@ const DRIFT_BLOCK_MARKER = /DRIFT-BLOCK\b/i;
150
152
  * READ-ONLY lanes — any entry (modified OR untracked) is an unexpected
151
153
  * mutation. Returns undefined when the tree is clean or git fails (fail-open:
152
154
  * a broken probe must not break task finalization). Note-only, never a fail.
155
+ * R-A2 exception: a FAILED GITDIR-POINTER GUARD is not a broken probe but a
156
+ * live corruption signal — it reports a mutation note (see body).
153
157
  */
154
158
  export async function detectReadLaneMutation(worktreePath: string): Promise<NonNullable<TeamTaskState["unexpectedMutation"]> | undefined> {
155
159
  try {
160
+ // R-A2 (MED-1 follow-up): this probe used to be a raw execFile with the
161
+ // FULL process.env — API keys could leak to worker-planted fsmonitor /
162
+ // hook config reachable from the probed worktree. It now routes through
163
+ // the MED-1 hardening: hardenGitArgs config-injection overrides, the
164
+ // sanitized git env (no process.env inheritance), and the gitdir-pointer
165
+ // guard. Guard failure is fail-safe TOWARD REPORTING a mutation: a
166
+ // corrupted `.git` pointer in a READ-ONLY lane is itself a lane
167
+ // violation (the worker rewrote something it must not touch), so it
168
+ // surfaces as a note + internal log instead of a clean verdict.
169
+ const trust = worktreeTrustCheck(worktreePath);
170
+ if (trust.kind === "worktree" && !trust.ok) {
171
+ logInternalError(
172
+ "post-execution.readLane.gitdirMismatch",
173
+ new Error(`Refusing to run the mutation probe inside ${worktreePath}: ${trust.reason}`),
174
+ `worktreePath=${worktreePath}`,
175
+ );
176
+ return {
177
+ filesChanged: 0,
178
+ sample: [`worktree corrupted — gitdir pointer check failed (${trust.reason}); no git executed`],
179
+ };
180
+ }
156
181
  // W10-8 one-shot git probe per read-only task — child_process is
157
182
  // already in the platform; dynamic keeps it off the static import graph.
158
183
  // LAZY: see above
159
184
  const { execFile } = await import("node:child_process");
160
185
  const out: string = await new Promise((resolve, reject) => {
161
- execFile("git", ["status", "--porcelain"], { cwd: worktreePath, encoding: "utf-8", timeout: 10_000 }, (err, stdout) =>
162
- err ? reject(err) : resolve(stdout),
186
+ execFile(
187
+ "git",
188
+ hardenGitArgs(["status", "--porcelain"]),
189
+ {
190
+ cwd: worktreePath,
191
+ env: buildGitSafeEnv(),
192
+ encoding: "utf-8",
193
+ timeout: 10_000,
194
+ windowsHide: true,
195
+ },
196
+ (err, stdout) => (err ? reject(err) : resolve(stdout)),
163
197
  );
164
198
  });
165
199
  const entries = out
@@ -122,11 +122,62 @@ export async function prepareBrokerSocketDir(sockPath: string): Promise<void> {
122
122
  }
123
123
  }
124
124
 
125
+ /** Literal-escaping helper for building filesystem-name regexes. */
126
+ function escapeRegExp(literal: string): string {
127
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
128
+ }
129
+
130
+ /**
131
+ * B-LOW1: sweep `${sockPath}.stopping.<pid>.<hex>` shield corpses left by
132
+ * broker/socket-stop.ts. stopServerAndRemoveSocket renames a foreign/unpinned
133
+ * socket leaf aside across the libuv close-unlink window and renames it back
134
+ * afterwards; a crash between those renames strands the shield forever — the
135
+ * plain removeStaleBrokerSocket flow only ever looks at `sockPath` itself, so
136
+ * the corpses previously accumulated one per crashed stop.
137
+ *
138
+ * A corpse is removed ONLY when the pid embedded in its name is provably dead
139
+ * (process.kill(pid, 0) → ESRCH). EPERM/any other signal result means the pid
140
+ * is alive (or its liveness cannot be proven) — the shield is kept (fail-safe:
141
+ * a live broker may still restore it). Best-effort: never throws.
142
+ */
143
+ async function sweepStoppingShields(sockPath: string): Promise<void> {
144
+ if (process.platform === "win32") return; // named pipes have no fs leaves
145
+ const dir = path.dirname(sockPath);
146
+ const pattern = new RegExp(`^${escapeRegExp(path.basename(sockPath))}\\.stopping\\.(\\d+)\\.[0-9a-f]{1,32}$`);
147
+ let entries: string[];
148
+ try {
149
+ entries = await fsp.readdir(dir);
150
+ } catch {
151
+ return;
152
+ }
153
+ for (const entry of entries) {
154
+ const match = pattern.exec(entry);
155
+ if (!match) continue;
156
+ const pid = Number.parseInt(match[1]!, 10);
157
+ if (!Number.isInteger(pid) || pid <= 0) continue;
158
+ try {
159
+ process.kill(pid, 0);
160
+ continue; // pid alive — keep the shield
161
+ } catch (err) {
162
+ const code = (err as NodeJS.ErrnoException).code;
163
+ if (code !== "ESRCH") continue; // cannot prove dead — keep
164
+ }
165
+ try {
166
+ await fsp.unlink(path.join(dir, entry));
167
+ } catch {
168
+ /* best-effort */
169
+ }
170
+ }
171
+ }
172
+
125
173
  /** Connect-then-unlink stale socket (herdr pattern). If a live broker is
126
174
  * listening, leave the endpoint intact (EADDRINUSE will surface on bind).
127
175
  * If a stale file exists with no listener, remove it. If the path is a
128
176
  * symlink, refuse rather than follow.
129
177
  *
178
+ * Also sweeps `${sockPath}.stopping.*` shield corpses whose recorded pid is
179
+ * dead (B-LOW1 — see sweepStoppingShields).
180
+ *
130
181
  * Returns "removed" when the stale entry was unlinked, "kept" when a live
131
182
  * listener was detected, "absent" when no entry existed, "refused"
132
183
  * when the entry is a symlink. */
@@ -138,6 +189,9 @@ export async function removeStaleBrokerSocket(
138
189
  // the timeout branch without racing a real localhost connect.
139
190
  netModule?: { createConnection: (path: string) => { once: (ev: string, fn: (arg?: unknown) => void) => void; destroy: () => void } },
140
191
  ): Promise<"removed" | "kept" | "absent" | "refused"> {
192
+ // B-LOW1: sweep stranded stop-shields first (best-effort, never affects the
193
+ // sockPath verdict below).
194
+ await sweepStoppingShields(sockPath);
141
195
  // Reject symlinks outright.
142
196
  let st: Awaited<ReturnType<typeof fsp.lstat>>;
143
197
  try {