pi-crew 0.11.49 → 0.11.51

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/index.mjs +551 -544
  3. package/package.json +1 -1
  4. package/skills/real-test-pi-crew/SKILL.md +2 -0
  5. package/src/agents/agent-config.ts +58 -1
  6. package/src/config/config-validation.ts +18 -0
  7. package/src/config/env-allowlist-registry.ts +5 -1
  8. package/src/config/env-vars.ts +5 -1
  9. package/src/config/sanitize-project-config.ts +55 -0
  10. package/src/extension/management.ts +9 -0
  11. package/src/extension/registration/lifecycle-handlers.ts +28 -0
  12. package/src/extension/registration/subagent-tools.ts +5 -1
  13. package/src/extension/run-import.ts +40 -6
  14. package/src/extension/team-tool/api/agent-control.ts +49 -0
  15. package/src/extension/team-tool/api/heartbeat.ts +48 -1
  16. package/src/extension/team-tool/api/mailbox.ts +81 -2
  17. package/src/extension/team-tool/api/read.ts +22 -0
  18. package/src/extension/team-tool/api/task-claims.ts +134 -3
  19. package/src/extension/team-tool/destructive-gate.ts +92 -8
  20. package/src/extension/team-tool/inspect.ts +8 -2
  21. package/src/extension/team-tool/lifecycle-actions.ts +9 -2
  22. package/src/extension/team-tool.ts +128 -27
  23. package/src/observability/exporters/otlp-exporter.ts +69 -19
  24. package/src/prompt/message-tool.ts +19 -8
  25. package/src/prompt/prompt-runtime.ts +160 -51
  26. package/src/runtime/broker/crew-broker-child.ts +55 -17
  27. package/src/runtime/broker/crew-broker-tokens.ts +24 -13
  28. package/src/runtime/broker/crew-broker.ts +12 -6
  29. package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
  30. package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
  31. package/src/runtime/child-pi/child-pi-steering.ts +8 -1
  32. package/src/runtime/child-pi/child-pi.ts +2 -1
  33. package/src/runtime/child-pi/inference-broker.ts +41 -7
  34. package/src/runtime/child-pi/pi-version.ts +31 -3
  35. package/src/runtime/child-pi/sandbox.ts +38 -1
  36. package/src/runtime/dwf-state-store.ts +193 -7
  37. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
  38. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
  39. package/src/runtime/live-session/live-session-runtime.ts +78 -3
  40. package/src/runtime/mcp-proxy.ts +65 -6
  41. package/src/runtime/model/pi-args.ts +16 -15
  42. package/src/runtime/plan-replan.ts +4 -1
  43. package/src/runtime/task-runner/child-executor.ts +11 -5
  44. package/src/runtime/task-runner/hybrid-tier.ts +17 -0
  45. package/src/runtime/task-runner/pre-execution.ts +13 -6
  46. package/src/runtime/task-runner.ts +14 -0
  47. package/src/runtime/team-runner.ts +26 -0
  48. package/src/runtime/verification/verification-worktree.ts +46 -29
  49. package/src/runtime/verification/verify-gate-memo.ts +211 -32
  50. package/src/runtime/verification/verify-gate.ts +266 -14
  51. package/src/runtime/workspace-lock.ts +95 -21
  52. package/src/schema/config-schema.ts +12 -2
  53. package/src/state/coordination/locks.ts +86 -19
  54. package/src/state/coordination/mailbox.ts +263 -22
  55. package/src/state/stores/manifest-io.ts +206 -7
  56. package/src/state/stores/state-store.ts +44 -15
  57. package/src/state/types.ts +9 -0
  58. package/src/ui/inline-panel/agent-transcript.ts +151 -11
  59. package/src/ui/settings-overlay.ts +8 -3
  60. package/src/ui/theme-discovery.ts +14 -2
  61. package/src/ui/tool-renderers/index.ts +26 -6
  62. package/src/utils/safe-terminal-text.ts +60 -0
  63. package/src/worktree/git-hardening.ts +78 -1
  64. package/src/worktree/worktree-manager.ts +34 -4
@@ -86,7 +86,12 @@ function isLockHolderAlive(filePath: string): boolean {
86
86
  * `heldByLiveInProcess: true` when the stored token belongs to a run-lock
87
87
  * acquisition of THIS process that is still inside its critical section — the
88
88
  * async acquire loop then WAITS (timer-based retry) instead of throwing or
89
- * stealing.
89
+ * stealing. W6 (steal-rm-dual-holder) additionally returns `holderToken` —
90
+ * the snapshotted corpse's token (undefined for a token-less legacy payload,
91
+ * a vanished file, or an unreadable snapshot) — so the acquire loop's
92
+ * destructive removal can be INSTANCE-BOUND to the verified corpse
93
+ * (removeLockIfTokenMatches) instead of deleting whatever currently occupies
94
+ * the path.
90
95
  *
91
96
  * ## EPERM Handling (Accepted Risk)
92
97
  *
@@ -115,7 +120,7 @@ function readLockSnapshot(
115
120
  filePath: string,
116
121
  staleMs: number,
117
122
  options?: { treatOwnPidAsStealable?: boolean; activeHolderTokens?: ReadonlySet<string> },
118
- ): { canSteal: boolean; heldByLiveInProcess: boolean } {
123
+ ): { canSteal: boolean; heldByLiveInProcess: boolean; holderToken?: string } {
119
124
  const treatOwnPidAsStealable = options?.treatOwnPidAsStealable === true;
120
125
  let stat: fs.Stats | undefined;
121
126
  let raw: string | undefined;
@@ -129,11 +134,11 @@ function readLockSnapshot(
129
134
  // "locked" error.
130
135
  const code = (error as NodeJS.ErrnoException).code;
131
136
  if (code === "ENOENT") {
132
- return { canSteal: true, heldByLiveInProcess: false };
137
+ return { canSteal: true, heldByLiveInProcess: false, holderToken: undefined };
133
138
  }
134
139
  // Transient I/O error — be conservative (don't steal, retry the read on
135
140
  // next attempt).
136
- return { canSteal: false, heldByLiveInProcess: false };
141
+ return { canSteal: false, heldByLiveInProcess: false, holderToken: undefined };
137
142
  }
138
143
  // Staleness from a single snapshot.
139
144
  let createdAt = parseCreatedAtFromLock(raw);
@@ -185,6 +190,7 @@ function readLockSnapshot(
185
190
  return {
186
191
  canSteal: isStale || !isAlive || ownPidStealable,
187
192
  heldByLiveInProcess: holderIsLiveInProcess,
193
+ holderToken,
188
194
  };
189
195
  }
190
196
 
@@ -250,6 +256,39 @@ function readLockToken(filePath: string): string | undefined {
250
256
  }
251
257
  }
252
258
 
259
+ /**
260
+ * W6 (steal-rm-dual-holder): INSTANCE-BOUND steal removal.
261
+ *
262
+ * Removes the lock file at `filePath` ONLY when its currently stored token
263
+ * still equals `corpseToken` — the token of the stale/dead holder that the
264
+ * caller's single-snapshot read verified as stealable. This binds the
265
+ * destructive removal to the exact file instance that was verified: a
266
+ * contender that created a FRESH lock between the snapshot and this removal
267
+ * carries a different randomUUID token (or ANY token at all when the corpse
268
+ * was a legacy token-less payload), which never matches → no removal, and the
269
+ * caller's retry loop re-snapshots cleanly instead of deleting a live
270
+ * holder's lock (the dual-holder window the un-tokened `fs.rmSync` created).
271
+ * Symlinks are refused via readLockToken (undefined for a symlink).
272
+ *
273
+ * Exported for unit tests of the steal-race fix; production callers are the
274
+ * steal branches of acquireLockWithRetry / acquireLockWithRetryAsync.
275
+ */
276
+ export function removeLockIfTokenMatches(filePath: string, corpseToken: string | undefined): boolean {
277
+ const currentToken = readLockToken(filePath);
278
+ const matches =
279
+ corpseToken === undefined
280
+ ? currentToken === undefined
281
+ : currentToken !== undefined && timingSafeTokenMatch(currentToken, corpseToken);
282
+ if (!matches) return false;
283
+ try {
284
+ fs.rmSync(filePath, { force: true });
285
+ return true;
286
+ } catch {
287
+ /* race — let the caller's loop retry */
288
+ return false;
289
+ }
290
+ }
291
+
253
292
  /**
254
293
  * Release a lock file, but ONLY if the stored token matches. This prevents
255
294
  * the "losing contender wipes winner's lock" race that occurs when:
@@ -456,7 +495,6 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
456
495
  const token = randomUUID();
457
496
  try {
458
497
  writeLockFile(filePath, token, kind);
459
- return token;
460
498
  } catch (error) {
461
499
  const code = (error as NodeJS.ErrnoException).code;
462
500
  if (!isLockContention(code)) throw error;
@@ -467,19 +505,37 @@ function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind
467
505
  // separate stale + alive reads (which could race stale→fresh).
468
506
  // treatOwnPidAsStealable=false (this is the sync file-lock path used
469
507
  // by withFileLockSync — never steal a fresh lock even if it has our pid).
470
- const { canSteal } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: false });
508
+ const { canSteal, holderToken: corpseToken } = readLockSnapshot(filePath, staleMs, { treatOwnPidAsStealable: false });
471
509
  if (!canSteal) {
472
510
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
473
511
  }
474
- // Stale or dead holder — forcibly remove the lock.
475
- try {
476
- fs.rmSync(filePath, { force: true });
477
- } catch {
478
- /* race — let loop retry */
479
- }
512
+ // Stale or dead holder — remove it. W6 (steal-rm-dual-holder): the
513
+ // removal is INSTANCE-BOUND — re-read the current holder's token and
514
+ // remove only if it still equals the snapshotted corpse token. Without
515
+ // this binding, a contender that created a FRESH lock between our
516
+ // snapshot and this removal would lose its lock to our rmSync, our
517
+ // create would then succeed, and BOTH of us would enter the critical
518
+ // section (dual holders, no post-create verification). On mismatch:
519
+ // no removal — the retry loop re-snapshots cleanly.
520
+ removeLockIfTokenMatches(filePath, corpseToken);
480
521
  sleepSync(Math.min(250, 25 * 2 ** attempt));
481
522
  attempt++;
523
+ continue;
482
524
  }
525
+ // W6 (steal-rm-dual-holder): post-claim verification, mirroring
526
+ // workspace-lock.ts claimLock/tryCreateVerified. O_EXCL proved WE created
527
+ // the file, but a concurrent stealer can remove our fresh file and claim
528
+ // its own in the microseconds before we return the token. Re-read: the
529
+ // on-disk token must be OURS before the acquire is considered successful.
530
+ // On mismatch: release best-effort (token-guarded → no-op against the
531
+ // foreign holder) and retry instead of returning a token we cannot prove.
532
+ if (readLockToken(filePath) === token) return token;
533
+ releaseLock(filePath, token);
534
+ if (Date.now() > deadline) {
535
+ throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
536
+ }
537
+ sleepSync(Math.min(250, 25 * 2 ** attempt));
538
+ attempt++;
483
539
  }
484
540
  }
485
541
 
@@ -494,7 +550,6 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
494
550
  const token = randomUUID();
495
551
  try {
496
552
  writeLockFile(filePath, token, kind);
497
- return token;
498
553
  } catch (error) {
499
554
  const code = (error as NodeJS.ErrnoException).code;
500
555
  if (!isLockContention(code)) throw error;
@@ -550,16 +605,28 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind
550
605
  "warn",
551
606
  );
552
607
  }
553
- // Stale or dead holder — forcibly remove the lock.
554
- try {
555
- fs.rmSync(filePath, { force: true });
556
- } catch {
557
- /* race — let loop retry */
558
- }
608
+ // Stale or dead holder — remove it. W6 (steal-rm-dual-holder): the
609
+ // removal is INSTANCE-BOUND to the snapshotted corpse token (see the
610
+ // sync twin above) — a contender's fresh lock created between our
611
+ // snapshot and this removal never matches and is NOT deleted; the
612
+ // retry loop re-snapshots instead of leaving two live holders.
613
+ removeLockIfTokenMatches(filePath, verdict.holderToken);
559
614
  const delay = Math.min(250, 25 * 2 ** attempt);
560
615
  await sleep(delay);
561
616
  attempt++;
617
+ continue;
618
+ }
619
+ // W6 (steal-rm-dual-holder): post-claim verification — same protocol as
620
+ // the sync twin above (mirrors workspace-lock.ts tryCreateVerified): the
621
+ // returned token is only trusted when the on-disk token is still OURS.
622
+ if (readLockToken(filePath) === token) return token;
623
+ releaseLock(filePath, token);
624
+ if (Date.now() > deadline) {
625
+ throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
562
626
  }
627
+ const delay = Math.min(250, 25 * 2 ** attempt);
628
+ await sleep(delay);
629
+ attempt++;
563
630
  }
564
631
  }
565
632
 
@@ -1,7 +1,9 @@
1
- import { randomUUID } from "node:crypto";
1
+ import type { KeyObject } from "node:crypto";
2
+ import { createHash, createPublicKey, sign as ed25519Sign, verify as ed25519Verify, generateKeyPairSync, randomUUID } from "node:crypto";
2
3
  import * as fs from "node:fs";
3
4
  import * as path from "node:path";
4
5
  import { DEFAULT_MAILBOX } from "../../config/defaults.ts";
6
+ import { getCrewEnv } from "../../config/env-vars.ts";
5
7
  import { logInternalError } from "../../utils/internal-error.ts";
6
8
  import { redactSecrets } from "../../utils/redaction.ts";
7
9
  import { atomicWriteFile } from "../atomic-write.ts";
@@ -22,6 +24,212 @@ export type MailboxDeliveryMode = "interrupt" | "next_turn";
22
24
  */
23
25
  export type MailboxReason = "need_decision" | "progress_update" | "interview_request";
24
26
 
27
+ // ============================================================================
28
+ // Coordination-row sender binding (w1a, coordination-durable-files-no-sender-binding).
29
+ //
30
+ // The durable coordination channels (mailbox JSONL + steering JSONL) are
31
+ // same-uid-writable by design: every WRITE-role child holds absolute paths to
32
+ // them (PI_CREW_STATE_ROOT / PI_CREW_STEERING_FILE). Before this section, every
33
+ // consumer trusted the file's caller-declared from/kind/to/taskId fields, so any
34
+ // worker could forge orchestrator-authority steers and leader/human-attributed
35
+ // messages with plain fs.appendFileSync.
36
+ //
37
+ // Fix: the parent/broker process SIGNS every row it writes with an Ed25519
38
+ // per-process keypair. The PRIVATE key is heap-only in the writer process
39
+ // (never persisted, never serialized, never placed in any child env). Children
40
+ // receive ONLY the public verify key (PI_CREW_MAILBOX_VERIFY_KEY, base64 SPKI
41
+ // DER — a public key, safe in env) set by child-pi-spawn at spawn time.
42
+ // Delivery-side readers (parseMailboxFile — the shared path under readMailbox /
43
+ // readAllMailboxMessages / pollWorkerInbox / replay) verify the signature over
44
+ // the identity-bearing fields and treat unverifiable rows as NOISE (dropped,
45
+ // not delivered). The key-possession constraint is respected: a shared HMAC
46
+ // secret distributed to children would let a malicious child forge valid rows;
47
+ // an Ed25519 public key cannot sign.
48
+ //
49
+ // Verification policy (delivery reads):
50
+ // - verify key available (env key in a child, or the in-process keypair in a
51
+ // writer) → a row is delivered ONLY with a valid signature; unsigned
52
+ // (legacy or forged) rows are noise. This is the fail-closed default the
53
+ // fix brief sanctions — including resume/replay: rows signed by a
54
+ // predecessor process verify against the NEW process keypair only if that
55
+ // key is the same, which it never is (heap-only per-process key), so
56
+ // pre-restart pending rows are dropped on replay. Documented tradeoff.
57
+ // - NO verify key available (standalone reader that never signed anything
58
+ // and carries no env key — e.g. offline tooling/test harnesses) → rows are
59
+ // kept without verification (inert-reader mode). Such a process is not a
60
+ // delivery sink: production children always carry the env key and
61
+ // production writer processes create their keypair at install/first-sign.
62
+ //
63
+ // validateMailbox()/updateMailboxMessageReply() deliberately keep using the
64
+ // shape-only parseMailboxMessage and preserve original line bytes on repair so
65
+ // repair tooling can neither drop nor forge authentication.
66
+ // ============================================================================
67
+
68
+ /** Child env var carrying the parent's base64 SPKI DER Ed25519 public verify
69
+ * key (set by child-pi-spawn). A PUBLIC key — non-secret, env-safe. */
70
+ export const COORDINATION_VERIFY_KEY_ENV = "PI_CREW_MAILBOX_VERIFY_KEY";
71
+
72
+ interface MailboxSigningKeypair {
73
+ privateKey: KeyObject;
74
+ publicKey: KeyObject;
75
+ publicBase64: string;
76
+ }
77
+
78
+ let signingKeypair: MailboxSigningKeypair | undefined;
79
+
80
+ function getMailboxSigningKeypair(): MailboxSigningKeypair {
81
+ if (!signingKeypair) {
82
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
83
+ const der = publicKey.export({ type: "spki", format: "der" });
84
+ signingKeypair = { privateKey, publicKey, publicBase64: Buffer.from(der).toString("base64") };
85
+ // A freshly created keypair changes the verification verdict for rows
86
+ // parsed before it existed (keyless → keyed). Drop memoized parses so no
87
+ // cached pre-key verdict survives the transition.
88
+ mailboxParseCache.clear();
89
+ }
90
+ return signingKeypair;
91
+ }
92
+
93
+ /** Public verify key (base64 SPKI DER) of this process's coordination signing
94
+ * keypair, creating the keypair on first use. Called by the writer process at
95
+ * install/spawn time so children can be handed the key, and at every sign. */
96
+ export function getMailboxVerifyKeyPublic(): string {
97
+ return getMailboxSigningKeypair().publicBase64;
98
+ }
99
+
100
+ /** Whether any verify key is resolvable in THIS process (child env key or an
101
+ * already-created in-process keypair). Keyless processes are inert readers. */
102
+ export function hasCoordinationVerifyKeys(): boolean {
103
+ return resolveCoordinationVerifyKeys().length > 0;
104
+ }
105
+
106
+ function resolveCoordinationVerifyKeys(): string[] {
107
+ const envKey = getCrewEnv(COORDINATION_VERIFY_KEY_ENV);
108
+ if (envKey && envKey.length > 0) return [envKey];
109
+ return signingKeypair ? [signingKeypair.publicBase64] : [];
110
+ }
111
+
112
+ /** Canonical payload string over the identity-bearing mailbox-row fields —
113
+ * fixed field order, undefined → null. `sig` itself is never included. `data`
114
+ * rides as its deterministic JSON serialization (writer and verifier both
115
+ * derive it from the same written file bytes). */
116
+ function canonicalMailboxPayload(row: Record<string, unknown>): string {
117
+ const pick = (key: string): unknown => (row[key] === undefined ? null : row[key]);
118
+ return JSON.stringify([
119
+ pick("id"),
120
+ pick("runId"),
121
+ pick("direction"),
122
+ pick("from"),
123
+ pick("to"),
124
+ pick("body"),
125
+ pick("createdAt"),
126
+ pick("status"),
127
+ pick("kind"),
128
+ pick("priority"),
129
+ pick("deliveryMode"),
130
+ pick("taskId"),
131
+ pick("questionId"),
132
+ pick("requestId"),
133
+ pick("reason"),
134
+ pick("replyTo"),
135
+ pick("replyFrom"),
136
+ pick("replyDeadline"),
137
+ pick("acknowledgedAt"),
138
+ row.data !== undefined && row.data !== null && typeof row.data === "object" ? JSON.stringify(row.data) : pick("data"),
139
+ ]);
140
+ }
141
+
142
+ function coordinationPayloadHash(canonical: string): string {
143
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
144
+ }
145
+
146
+ function signPayloadHash(payloadHash: string): string {
147
+ const { privateKey } = getMailboxSigningKeypair();
148
+ return ed25519Sign(null, Buffer.from(payloadHash, "utf8"), privateKey).toString("base64");
149
+ }
150
+
151
+ /** Verify a mailbox row's Ed25519 signature against explicit base64 SPKI keys
152
+ * (default: the keys resolvable in this process). False for missing/invalid
153
+ * signatures and malformed keys — never throws. */
154
+ export function verifyMailboxRowSig(row: Record<string, unknown>, verifyKeys?: string[]): boolean {
155
+ const sig = row.sig;
156
+ if (typeof sig !== "string" || sig.length === 0) return false;
157
+ const keys = verifyKeys ?? resolveCoordinationVerifyKeys();
158
+ if (keys.length === 0) return false;
159
+ const payloadHash = coordinationPayloadHash(canonicalMailboxPayload(row));
160
+ let sigBuf: Buffer;
161
+ try {
162
+ sigBuf = Buffer.from(sig, "base64");
163
+ } catch {
164
+ return false;
165
+ }
166
+ for (const key of keys) {
167
+ try {
168
+ const pub = createPublicKey({ key: Buffer.from(key, "base64"), format: "der", type: "spki" });
169
+ if (ed25519Verify(null, Buffer.from(payloadHash, "utf8"), pub, sigBuf)) return true;
170
+ } catch {
171
+ /* malformed key material — try the next */
172
+ }
173
+ }
174
+ return false;
175
+ }
176
+
177
+ /** Stamp this process's Ed25519 signature onto a mailbox message. Called at
178
+ * every durable mailbox write (append + reply rewrite) in the writer process. */
179
+ export function signMailboxMessage(message: MailboxMessage): MailboxMessage & { sig: string } {
180
+ // Spread into an anonymous object literal (assignable to Record<string, unknown>
181
+ // without a cast — named interfaces lack implicit index signatures, anonymous
182
+ // object types do not) so the canonical payload sees every MailboxMessage field.
183
+ const row: Record<string, unknown> = { ...message };
184
+ return { ...message, sig: signPayloadHash(coordinationPayloadHash(canonicalMailboxPayload(row))) };
185
+ }
186
+
187
+ /** Steering-JSONL line shape shared by every writer (team-tool, broker
188
+ * steer.push, child-executor seed, wrap-up advisories). */
189
+ export interface CoordinationSteerEntry {
190
+ type?: string;
191
+ message?: string;
192
+ id?: string;
193
+ ts?: string;
194
+ }
195
+
196
+ /** Canonical payload for a steering line — fixed field order (type, message,
197
+ * id, ts); `sig` never included. */
198
+ function canonicalSteeringPayload(entry: CoordinationSteerEntry): string {
199
+ return JSON.stringify([entry.type ?? null, entry.message ?? null, entry.id ?? null, entry.ts ?? null]);
200
+ }
201
+
202
+ /** Stamp this process's Ed25519 signature onto a steering JSONL entry. */
203
+ export function signSteeringEntry<T extends CoordinationSteerEntry>(entry: T): T & { sig: string } {
204
+ return { ...entry, sig: signPayloadHash(coordinationPayloadHash(canonicalSteeringPayload(entry))) };
205
+ }
206
+
207
+ /** Verify a steering line's signature against this process's resolvable keys.
208
+ * False for missing/invalid signatures; never throws. */
209
+ export function verifySteeringEntrySig(entry: unknown): boolean {
210
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
211
+ const sig = (entry as Record<string, unknown>).sig;
212
+ if (typeof sig !== "string" || sig.length === 0) return false;
213
+ const keys = resolveCoordinationVerifyKeys();
214
+ if (keys.length === 0) return false;
215
+ const payloadHash = coordinationPayloadHash(canonicalSteeringPayload(entry as CoordinationSteerEntry));
216
+ let sigBuf: Buffer;
217
+ try {
218
+ sigBuf = Buffer.from(sig, "base64");
219
+ } catch {
220
+ return false;
221
+ }
222
+ for (const key of keys) {
223
+ try {
224
+ const pub = createPublicKey({ key: Buffer.from(key, "base64"), format: "der", type: "spki" });
225
+ if (ed25519Verify(null, Buffer.from(payloadHash, "utf8"), pub, sigBuf)) return true;
226
+ } catch {
227
+ /* malformed key material — try the next */
228
+ }
229
+ }
230
+ return false;
231
+ }
232
+
25
233
  // ============================================================================
26
234
  // Phase 1.3: post-append observer (single notification point)
27
235
  // ============================================================================
@@ -139,6 +347,14 @@ function mailboxDir(manifest: TeamRunManifest): string {
139
347
  return path.join(manifest.stateRoot, "mailbox");
140
348
  }
141
349
 
350
+ /** w1a: durable serialize + Ed25519 sign of a mailbox row. Signature is
351
+ * computed over the REDACTED row (the exact bytes that hit the file), so a
352
+ * verifier recomputing the canonical payload from the parsed line matches. */
353
+ function serializeMailboxRow(message: MailboxMessage): string {
354
+ const prepared = redactSecrets(message) as MailboxMessage;
355
+ return JSON.stringify({ ...prepared, sig: signMailboxMessage(prepared).sig });
356
+ }
357
+
142
358
  function safeMailboxDir(manifest: TeamRunManifest, create = false): string {
143
359
  const dir = mailboxDir(manifest);
144
360
  if (create) {
@@ -352,13 +568,30 @@ function parseMailboxMessage(raw: unknown, expectedDirection: MailboxDirection):
352
568
  }
353
569
 
354
570
  /** Raw read+parse of one mailbox JSONL file (extracted verbatim from the old
355
- * readMailboxFile body). Callers go through cachedMailboxRead instead. */
571
+ * readMailboxFile body). Callers go through cachedMailboxRead instead.
572
+ *
573
+ * w1a sender-binding GATE: this is THE shared delivery-read path (readMailbox,
574
+ * readAllMailboxMessages → pollWorkerInbox / msg.inbox / replay). When this
575
+ * process can verify coordination signatures (child env key or an existing
576
+ * writer keypair), a row is delivered ONLY with a valid Ed25519 signature —
577
+ * unsigned (legacy/forged) rows and tampered rows are dropped as noise.
578
+ * Keyless processes (no env key, keypair never created) keep rows without
579
+ * verification — inert-reader mode, never a delivery sink in production.
580
+ * Shape-only parsing (validateMailbox / reply rewrite) deliberately bypasses
581
+ * this gate via parseMailboxMessage. */
356
582
  function parseMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] {
357
583
  const messages: MailboxMessage[] = [];
584
+ const verifyKeys = resolveCoordinationVerifyKeys();
358
585
  const raw = fs.readFileSync(filePath, "utf-8");
359
586
  for (const line of raw.split(/\r?\n/).filter(Boolean)) {
360
587
  try {
361
- const message = parseMailboxMessage(JSON.parse(line) as unknown, direction);
588
+ const parsedLine = JSON.parse(line) as unknown;
589
+ if (verifyKeys.length > 0) {
590
+ if (!parsedLine || typeof parsedLine !== "object" || Array.isArray(parsedLine)) continue;
591
+ const row = parsedLine as Record<string, unknown>;
592
+ if (typeof row.sig !== "string" || !verifyMailboxRowSig(row, verifyKeys)) continue;
593
+ }
594
+ const message = parseMailboxMessage(parsedLine, direction);
362
595
  if (message) messages.push(message);
363
596
  } catch {
364
597
  // Invalid mailbox lines are reported by validateMailbox().
@@ -840,14 +1073,16 @@ function buildMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendIn
840
1073
  /**
841
1074
  * Append a message to a run's or task's mailbox.
842
1075
  *
843
- * SECURITY NOTE: The `from` field is caller-declared — there is no cryptographic
844
- * sender authentication. This is acceptable because `appendMailboxMessage` is an
845
- * internal API only callable from within the pi-crew process (no external input).
846
- * All callers (handleSteer, handleRespond, handleFollowUp) derive `from` from
847
- * authenticated context (session role, task assignment).
848
- *
849
- * If pi-crew ever exposes mailbox writes to external/untrusted input, sender
850
- * authentication (HMAC or session key) must be added.
1076
+ * SECURITY NOTE (updated w1a — coordination-durable-files-no-sender-binding):
1077
+ * The `from` field is caller-declared within the pi-crew process; every row
1078
+ * this module writes is stamped with the writer process's Ed25519 signature
1079
+ * over the identity-bearing fields (see the sender-binding section above), and
1080
+ * every DELIVERY-side consumer (readMailbox / readAllMailboxMessages →
1081
+ * pollWorkerInbox, msg.inbox, replay) drops rows without a valid signature.
1082
+ * Workers and same-uid processes can still APPEND bytes to the JSONL files,
1083
+ * but those rows no longer deliver: they verify against no trusted key. The
1084
+ * private signing key never leaves the parent process heap; children hold only
1085
+ * the public verify key (PI_CREW_MAILBOX_VERIFY_KEY).
851
1086
  */
852
1087
  export function appendMailboxMessage(manifest: TeamRunManifest, message: MailboxAppendInput): MailboxMessage {
853
1088
  if (message.taskId) ensureTaskMailbox(manifest, message.taskId);
@@ -866,11 +1101,7 @@ export function appendMailboxMessage(manifest: TeamRunManifest, message: Mailbox
866
1101
  // with appends — otherwise a concurrent append between rename and recreate can
867
1102
  // be truncated to an empty file.
868
1103
  withFileLockSync(mailboxFile(manifest, complete.direction, complete.taskId), () => {
869
- fs.appendFileSync(
870
- mailboxFile(manifest, complete.direction, complete.taskId),
871
- `${JSON.stringify(redactSecrets(complete))}\n`,
872
- "utf-8",
873
- );
1104
+ fs.appendFileSync(mailboxFile(manifest, complete.direction, complete.taskId), `${serializeMailboxRow(complete)}\n`, "utf-8");
874
1105
  // 3.3 — rotate mailbox file if it has grown past 10 MB. Cheap stat check;
875
1106
  // rotates at most once per append.
876
1107
  rotateMailboxFileIfNeeded(mailboxFile(manifest, complete.direction, complete.taskId));
@@ -958,7 +1189,7 @@ export async function appendMailboxMessageAsync(manifest: TeamRunManifest, messa
958
1189
  const complete = buildMailboxMessage(manifest, message, createdAt);
959
1190
  const mbFile = mailboxFile(manifest, complete.direction, complete.taskId);
960
1191
  await withFileLockAsync(mbFile, async () => {
961
- await fs.promises.appendFile(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
1192
+ await fs.promises.appendFile(mbFile, `${serializeMailboxRow(complete)}\n`, "utf-8");
962
1193
  rotateMailboxFileIfNeeded(mbFile);
963
1194
  });
964
1195
  // R1 review fix / plan §5 #6: delivery RMW uses the cross-process sync
@@ -1086,7 +1317,7 @@ export function appendMailboxMessageIdempotent(
1086
1317
  stored = existing;
1087
1318
  return;
1088
1319
  }
1089
- fs.appendFileSync(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
1320
+ fs.appendFileSync(mbFile, `${serializeMailboxRow(complete)}\n`, "utf-8");
1090
1321
  rotateMailboxFileIfNeeded(mbFile);
1091
1322
  });
1092
1323
  if (stored) return { message: stored, deduped: true };
@@ -1115,7 +1346,7 @@ export async function appendMailboxMessageIdempotentAsync(
1115
1346
  stored = existing;
1116
1347
  return;
1117
1348
  }
1118
- await fs.promises.appendFile(mbFile, `${JSON.stringify(redactSecrets(complete))}\n`, "utf-8");
1349
+ await fs.promises.appendFile(mbFile, `${serializeMailboxRow(complete)}\n`, "utf-8");
1119
1350
  rotateMailboxFileIfNeeded(mbFile);
1120
1351
  });
1121
1352
  if (stored) return { message: stored, deduped: true };
@@ -1327,7 +1558,9 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
1327
1558
  if (msg && msg.id === originalMessageId) {
1328
1559
  msg.repliedAt = new Date().toISOString();
1329
1560
  msg.replyContent = replyContent;
1330
- updatedLines.push(JSON.stringify(redactSecrets(msg)));
1561
+ // w1a: the rewrite mutates signed fields — re-sign so the
1562
+ // row stays authentic under the writer process key.
1563
+ updatedLines.push(serializeMailboxRow(msg));
1331
1564
  localFound = true;
1332
1565
  } else {
1333
1566
  updatedLines.push(line);
@@ -1361,7 +1594,9 @@ export function updateMailboxMessageReply(manifest: TeamRunManifest, originalMes
1361
1594
  // Recreate the live file if it went missing — the archives in this
1362
1595
  // directory prove the mailbox itself is valid.
1363
1596
  if (!fs.existsSync(filePath)) atomicWriteFile(filePath, "");
1364
- fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets(compensating))}\n`, "utf-8");
1597
+ // w1a: compensating records are NEW durable rows — sign them like any
1598
+ // append, or the delivery-read gate would drop them as unauthenticated.
1599
+ fs.appendFileSync(filePath, `${serializeMailboxRow(compensating)}\n`, "utf-8");
1365
1600
  // The append may itself trigger rotation — safe: the compensating
1366
1601
  // record then lands in the NEWEST archive, which sorts after the
1367
1602
  // original's archive in the merge order, so it still wins by id.
@@ -1431,7 +1666,13 @@ export function validateMailbox(
1431
1666
  const parsed = JSON.parse(line) as unknown;
1432
1667
  const message = parseMailboxMessage(parsed, direction);
1433
1668
  if (!message) throw new Error("invalid message schema");
1434
- validLines.push(JSON.stringify(redactSecrets(message)));
1669
+ // w1a: preserve the ORIGINAL line bytes for valid rows. The old
1670
+ // re-serialization here stripped Ed25519 signatures from every
1671
+ // valid line on a repair pass (repair only rewrites when at least
1672
+ // one line was invalid), silently de-authenticating the whole
1673
+ // file. Repair must never drop NOR forge authentication — keep
1674
+ // the row exactly as written.
1675
+ validLines.push(line);
1435
1676
  } catch (error) {
1436
1677
  const message = error instanceof Error ? error.message : String(error);
1437
1678
  issues.push({ level: "error", path: filePath, message });