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
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import * as fs from "node:fs";
16
+ import { signSteeringEntry } from "../../state/coordination/mailbox.ts";
16
17
  import { logInternalError } from "../../utils/internal-error.ts";
17
18
  import { killProcessTree } from "./child-pi-kill.ts";
18
19
 
@@ -74,7 +75,13 @@ export class ChildPiSteeringController {
74
75
  // a failed write must NOT kill the worker.
75
76
  if (steeringFile) {
76
77
  try {
77
- fs.appendFileSync(steeringFile, JSON.stringify({ type: "steer", message: STEER_WRAP_UP_MESSAGE }) + "\n", "utf-8");
78
+ // w1a sender binding: sign the advisory line (steering file is
79
+ // same-uid writable; worker pollSteering drops unauthenticated lines).
80
+ fs.appendFileSync(
81
+ steeringFile,
82
+ `${JSON.stringify(signSteeringEntry({ type: "steer", message: STEER_WRAP_UP_MESSAGE }))}\n`,
83
+ "utf-8",
84
+ );
78
85
  } catch (err) {
79
86
  logInternalError("child-pi.steer-write-failed", err instanceof Error ? err : new Error(String(err)), `pid=${pid}`);
80
87
  }
@@ -281,7 +281,8 @@ export interface ChildPiRunInput {
281
281
  * in the parent's in-memory `Map<runId, token>` and in the child's env.
282
282
  * NEVER persisted to manifest, events, mailbox, or any run-dir file.
283
283
  *
284
- * OPT-IN fd3 route (PI_CREW_BROKER_STDIN=1, FIX-1 2026-10-28): the token
284
+ * DEFAULT-ON fd3 route (PI_CREW_BROKER_STDIN — '0' opts out; DEFAULT-ON
285
+ * flip 2026-10-28, FIX-1 2026-10-28): the token
285
286
  * is still written into built.env for every brokered spawn — RPC/surface
286
287
  * branches compose WITHOUT the payload and need the env credential, but
287
288
  * the headless JSON branch composes WITH ctx.brokerStdinPayload and
@@ -377,6 +377,10 @@ async function handleRequest(
377
377
  res: http.ServerResponse,
378
378
  ctx: { routes: Map<string, ProviderRoute>; workers: Map<string, WorkerEntry> },
379
379
  ): Promise<void> {
380
+ // Assigned AFTER concurrency admission (step 2b). The catch path releases
381
+ // the slot for body-read/upstream failures that bypass the normal
382
+ // response-close settlement; the release itself is idempotent.
383
+ let releaseSlot: (() => void) | undefined;
380
384
  try {
381
385
  // (0) Provider routing: the FIRST path segment names the provider
382
386
  // ("/anthropic/v1/messages", "/openai/chat/completions"); the broker
@@ -411,12 +415,35 @@ async function handleRequest(
411
415
  deny(res, 429, "too many concurrent inference requests for this worker");
412
416
  return;
413
417
  }
418
+ // (2b) Take the concurrency slot BEFORE the body read, and release it
419
+ // EXACTLY once on every post-admission exit path: the "close" handler
420
+ // covers normal completion, client aborts, and post-admission denies
421
+ // (deny() ends the response → close fires), and the idempotent guard
422
+ // makes the explicit catch-path call a no-op when close already fired.
423
+ // Without the matching increment the >= cap gate above was dead code
424
+ // (inFlight only ever walked negative after the first response close),
425
+ // so one valid worker token could hold unbounded concurrent requests —
426
+ // each buffering up to MAX_BODY_BYTES in parent-process memory — for
427
+ // the life of the token.
428
+ worker.inFlight += 1;
429
+ const usage = new UsageCounter();
430
+ let settled = false;
431
+ const settle = (): void => {
432
+ if (settled) return;
433
+ settled = true;
434
+ worker.inFlight -= 1;
435
+ worker.inputTokens += usage.input;
436
+ worker.outputTokens += usage.output;
437
+ };
438
+ releaseSlot = settle;
439
+ res.on("close", settle);
414
440
  // (3) Read the request body (bounded).
415
441
  const chunks: Buffer[] = [];
416
442
  let size = 0;
417
443
  for await (const chunk of req) {
418
444
  size += (chunk as Buffer).length;
419
445
  if (size > MAX_BODY_BYTES) {
446
+ settle();
420
447
  deny(res, 413, "request body too large");
421
448
  return;
422
449
  }
@@ -445,13 +472,8 @@ async function handleRequest(
445
472
  });
446
473
  res.writeHead(upstreamRes.status, outHeaders);
447
474
  worker.requests += 1;
448
- const usage = new UsageCounter();
449
- const finish = () => {
450
- worker.inFlight -= 1;
451
- worker.inputTokens += usage.input;
452
- worker.outputTokens += usage.output;
453
- };
454
- res.on("close", finish);
475
+ // Slot settlement + usage accounting were wired at admission (2b);
476
+ // "close" fires here on normal completion (res.end / pipe drain).
455
477
  if (!upstreamRes.body) {
456
478
  res.end();
457
479
  return;
@@ -463,6 +485,10 @@ async function handleRequest(
463
485
  });
464
486
  nodeStream.pipe(usage.tee).pipe(res);
465
487
  } catch (error) {
488
+ // Release the admitted concurrency slot for body-read/upstream failures
489
+ // (client abort mid-body, fetch throw, ...) — idempotent when the
490
+ // response close already settled it.
491
+ releaseSlot?.();
466
492
  logInternalError("inference-broker.forward", undefined, error instanceof Error ? error.message : String(error), "warn");
467
493
  if (!res.headersSent) deny(res, 502, "inference broker upstream failure");
468
494
  else res.destroy();
@@ -648,3 +674,11 @@ export function __resetInferenceBrokerForTests(): void {
648
674
  export function __inferenceBrokerTokenMapKeysForTests(): string[] {
649
675
  return broker ? [...broker.workers.keys()] : [];
650
676
  }
677
+
678
+ /** @internal TESTS ONLY — the live per-worker in-flight counter (looked up
679
+ * by worker key, never the raw token). Pins the MAX_INFLIGHT_PER_WORKER
680
+ * admission cap: incremented at admission, exactly one release per settled
681
+ * request (success, upstream failure, client abort, post-admission deny). */
682
+ export function __inferenceBrokerInFlightForTests(workerKey: string): number | undefined {
683
+ return broker?.byKey.get(workerKey)?.inFlight;
684
+ }
@@ -19,8 +19,11 @@
19
19
  * a real child; the default seam wraps node:child_process execFile with a
20
20
  * 5s timeout (utf8).
21
21
  * - Single-flight memoized per process: the FIRST call creates the promise;
22
- * every later call (any args) returns the same one. Failures memoize too
23
- * (null = unknown) — a missing/old pi binary does not re-probe per spawn.
22
+ * every later call (any args) returns the same one. SUCCESSFUL probes memoize
23
+ * forever; FAILED/unknown probes (null) re-arm after a short TTL
24
+ * (PROBE_FAILURE_RETRY_TTL_MS, 60s) — one transient cold-start failure must
25
+ * not permanently drop version-gated safety flags (--no-mcp) for the
26
+ * process lifetime (SEC-2026-11 N2: parity-spawn-mcp-cut-version-gated).
24
27
  * - NEVER rejects: spawn error / nonzero exit / timeout / unparseable
25
28
  * stdout all resolve null. The result is a hint, never a failure channel.
26
29
  *
@@ -155,6 +158,15 @@ export function piVersionAtLeast(version: string | null | undefined, floor: stri
155
158
  /** Memoized single-flight probe promise (null = unknown, failures included). */
156
159
  let memoizedProbe: Promise<string | null> | null = null;
157
160
 
161
+ /**
162
+ * SEC-2026-11 N2: how long a FAILED/unknown probe stays memoized before a
163
+ * later spawn may retry. Successes memoize forever; failures must not —
164
+ * a single transient probe miss (e.g. 5s cold-start timeout) otherwise drops
165
+ * --no-mcp from every later parity spawn in the process, silently widening
166
+ * the MCP exposure window on hosts that DO support the flag.
167
+ */
168
+ export const PROBE_FAILURE_RETRY_TTL_MS = 60_000;
169
+
158
170
  async function runPiVersionProbe(exec: ExecPiVersionFn): Promise<string | null> {
159
171
  const { command, args } = getPiSpawnCommand(["--version"]);
160
172
  const { stdout } = await exec(command, args, { timeout: PI_VERSION_PROBE_TIMEOUT_MS, encoding: "utf8" });
@@ -170,7 +182,23 @@ async function runPiVersionProbe(exec: ExecPiVersionFn): Promise<string | null>
170
182
  * matter what they pass. NEVER rejects; unknown resolves null.
171
183
  */
172
184
  export function probePiVersion(exec: ExecPiVersionFn = defaultExec): Promise<string | null> {
173
- if (!memoizedProbe) memoizedProbe = runPiVersionProbe(exec).catch(() => null);
185
+ if (!memoizedProbe) {
186
+ const memo = runPiVersionProbe(exec)
187
+ .catch(() => null)
188
+ .then((version) => {
189
+ if (version === null) {
190
+ // Failure/unknown: re-arm the memo after the TTL so a later spawn
191
+ // retries (see PROBE_FAILURE_RETRY_TTL_MS). Only clear OUR memo — a
192
+ // concurrent success must not be evicted.
193
+ const timer: NodeJS.Timeout = setTimeout(() => {
194
+ if (memoizedProbe === memo) memoizedProbe = null;
195
+ }, PROBE_FAILURE_RETRY_TTL_MS);
196
+ timer.unref();
197
+ }
198
+ return version;
199
+ });
200
+ memoizedProbe = memo;
201
+ }
174
202
  return memoizedProbe;
175
203
  }
176
204
 
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { type SpawnSyncOptions, type SpawnSyncReturns, spawnSync } from "node:child_process";
30
30
  import * as fs from "node:fs";
31
+ import * as os from "node:os";
31
32
  import * as path from "node:path";
32
33
 
33
34
  export const SANDBOX_ENV = "PI_CREW_SANDBOX";
@@ -122,14 +123,33 @@ function toolchainBinds(spec: { command: string; args: string[] }, isPackageRoot
122
123
  }
123
124
  }
124
125
  // --extension / --skill resources: bind the value + ancestors up to the
125
- // nearest package root (extensions import across their package tree).
126
+ // nearest package root (extensions import across their package tree) —
127
+ // but NEVER at or above the user trust boundary. User-global skill roots
128
+ // (~/.pi/agent/skills — the default pi user-skill location) have NO
129
+ // package.json ancestor before $HOME, so an uncapped walk climbed all the
130
+ // way up and emitted `--ro-bind-try $HOME $HOME` (plus `~/.pi/agent`, the
131
+ // auth.json directory), mounting the whole host home — credentials
132
+ // included — read-only inside the sandbox. The walk now stops BELOW the
133
+ // pi agent dir / $HOME, and a value that would mount the home, the agent
134
+ // dir, or any ancestor containing either is SKIPPED entirely (fail-closed
135
+ // ENOENT inside the sandbox — never a HOME bind).
136
+ const home = os.homedir();
137
+ const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent");
138
+ // Protect the DEFAULT agent dir too: a PI_CODING_AGENT_DIR override moves
139
+ // the effective dir, but ~/.pi/agent keeps holding real credentials on
140
+ // default installs — fail closed for both locations.
141
+ const trustBoundaries = [home, agentDir, path.join(home, ".pi", "agent")];
126
142
  for (let i = 0; i < spec.args.length - 1; i++) {
127
143
  const flag = spec.args[i];
128
144
  if (flag !== "--extension" && flag !== "--skill") continue;
129
145
  const value = spec.args[i + 1];
130
146
  if (!value || !path.isAbsolute(value)) continue;
147
+ if (crossesUserTrustBoundary(value, trustBoundaries)) continue;
131
148
  binds.add(value);
132
149
  for (let dir = path.dirname(value); dir !== path.dirname(dir); dir = path.dirname(dir)) {
150
+ // never climb at/above the user trust boundary: the pi agent dir
151
+ // (auth.json!) and the home dir itself stay unmounted
152
+ if (crossesUserTrustBoundary(dir, trustBoundaries)) break;
133
153
  binds.add(dir);
134
154
  if (isPackageRoot(dir)) break;
135
155
  }
@@ -137,6 +157,23 @@ function toolchainBinds(spec: { command: string; args: string[] }, isPackageRoot
137
157
  return [...binds];
138
158
  }
139
159
 
160
+ /**
161
+ * True when binding `dir` would mount a trust boundary itself or any
162
+ * ancestor CONTAINING one (e.g. `dir` == $HOME, ~/.pi, /home, or /) — the
163
+ * caller must skip/break (fail closed) instead of binding. Comparison goes
164
+ * through path.relative, which is case-insensitive on win32.
165
+ */
166
+ function crossesUserTrustBoundary(dir: string, boundaries: string[]): boolean {
167
+ for (const boundary of boundaries) {
168
+ const rel = path.relative(dir, boundary);
169
+ // rel === "" → dir IS the boundary; a rel that stays inside dir (no
170
+ // ".." prefix, not absolute) → the boundary lives UNDER dir, so
171
+ // binding dir would mount it.
172
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) return true;
173
+ }
174
+ return false;
175
+ }
176
+
140
177
  /** Default package-root probe: a directory carrying package.json (extensions import across their package tree). */
141
178
  function defaultIsPackageRoot(dir: string): boolean {
142
179
  try {
@@ -13,6 +13,14 @@
13
13
  * corrupt checkpoint is treated as a fresh run (load() returns undefined). If a crash
14
14
  * happens mid-agent, that agent simply re-runs from scratch on resume — agent results
15
15
  * are expected to be idempotent-ish.
16
+ *
17
+ * w3-dwf-trust (2026-10-29): the checkpoint path (<crewRoot>/state/runs/<runId>/) is
18
+ * writable by every WRITE-role child worker of the run (same OS uid), so the "sole
19
+ * writer is the runner" assumption is FALSE. load() therefore validates at the
20
+ * hydration boundary: runId binding (expectedRunId), numeric clamps on spent/
21
+ * agentCount, container-shape checks, and a producer-shape verdict allowlist over
22
+ * completedAgentCalls entries — forged or schema-invalid state fails closed to
23
+ * re-execution instead of replaying as trusted. See sanitizeCheckpoint below.
16
24
  */
17
25
 
18
26
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
@@ -59,6 +67,157 @@ export interface DwfCheckpointState {
59
67
  updatedAt: string;
60
68
  }
61
69
 
70
+ // ─── w3-dwf-trust (2026-10-29): hydration-boundary validation ─────────────
71
+ // The checkpoint file lives under the shared repo where WRITE-role child
72
+ // workers can write it, so load() revalidates instead of blind-casting. These
73
+ // helpers narrow the parsed JSON (typed as `unknown` — no `any`) into a
74
+ // DwfCheckpointState, dropping or resetting anything the runner could not
75
+ // have produced. Fail-closed policy: schema-invalid containers reject the
76
+ // whole checkpoint (fresh run); invalid per-call entries are dropped (call
77
+ // re-executes); invalid numeric accumulators clamp to 0.
78
+
79
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
80
+ typeof value === "object" && value !== null && !Array.isArray(value);
81
+
82
+ /** String[] narrowing: non-array → undefined (reject), non-string elements filtered out. */
83
+ const sanitizeStringArray = (value: unknown): string[] | undefined => {
84
+ if (!Array.isArray(value)) return undefined;
85
+ return value.filter((entry): entry is string => typeof entry === "string");
86
+ };
87
+
88
+ /** Numeric control accumulator clamp: finite non-negative numbers pass; anything else → 0
89
+ * (a forged negative `spent` inflated budget.remaining() past the dispatched cap, and a
90
+ * negative/reset `agentCount` re-armed the agent-call cap). */
91
+ const clampNonNegativeNumber = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0);
92
+
93
+ /** Optional finite non-negative number field (usage parts, durationMs): keep when valid, drop when not. */
94
+ const optionalNonNegativeNumber = (value: unknown): number | undefined =>
95
+ typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
96
+
97
+ /** Validate one hydrated completedAgentCalls entry against the runner's producer shape.
98
+ * Verdict allowlist: the runner caches ONLY successful results (ok exactly boolean true,
99
+ * string text) — anything else was never produced by loadWorkflowModule's runner, so it
100
+ * is dropped (the call re-executes on resume) rather than replayed with ok forced true.
101
+ * Optional fields are kept only when correctly typed; `structured` is post-JSON-parse by
102
+ * construction and survives as-is (deep schema re-validation needs the call's schema,
103
+ * which the checkpoint format does not carry — the dispatch-side pin/runId binding is
104
+ * the load-bearing control for forged-but-well-formed entries). */
105
+ function sanitizeAgentResult(value: unknown): AgentResult | undefined {
106
+ if (!isPlainObject(value)) return undefined;
107
+ if (value.ok !== true) return undefined;
108
+ if (typeof value.text !== "string") return undefined;
109
+ const result: AgentResult = { ok: true, text: value.text };
110
+ if (value.structured !== undefined) result.structured = value.structured;
111
+ if (isPlainObject(value.usage)) {
112
+ const usage: AgentResult["usage"] = {};
113
+ const input = optionalNonNegativeNumber(value.usage.input);
114
+ const output = optionalNonNegativeNumber(value.usage.output);
115
+ const cost = optionalNonNegativeNumber(value.usage.cost);
116
+ const turns = optionalNonNegativeNumber(value.usage.turns);
117
+ if (input !== undefined) usage.input = input;
118
+ if (output !== undefined) usage.output = output;
119
+ if (cost !== undefined) usage.cost = cost;
120
+ if (turns !== undefined) usage.turns = turns;
121
+ if (Object.keys(usage).length > 0) result.usage = usage;
122
+ }
123
+ if (typeof value.runId === "string") result.runId = value.runId;
124
+ if (typeof value.taskId === "string") result.taskId = value.taskId;
125
+ if (typeof value.artifactPath === "string") result.artifactPath = value.artifactPath;
126
+ if (typeof value.error === "string") result.error = value.error;
127
+ const durationMs = optionalNonNegativeNumber(value.durationMs);
128
+ if (durationMs !== undefined) result.durationMs = durationMs;
129
+ return result;
130
+ }
131
+
132
+ /** Bind + revalidate a version-guarded checkpoint record into a DwfCheckpointState.
133
+ * Returns undefined when the record is schema-invalid or bound to another run
134
+ * (fail closed to re-execution, never trusted replay). */
135
+ function sanitizeCheckpoint(record: Record<string, unknown>, expectedRunId: string | undefined): DwfCheckpointState | undefined {
136
+ // (1) RunId binding: the runner passes manifest.runId, so a checkpoint for a
137
+ // different (stale/planted/enumerated) run never hydrates as this run's state.
138
+ if (expectedRunId !== undefined && record.runId !== expectedRunId) {
139
+ logInternalError(
140
+ "dwf-state-store.load",
141
+ new Error("checkpoint runId mismatch — rejecting checkpoint, run restarts fresh"),
142
+ `expectedRunId=${expectedRunId}, checkpointRunId=${record.runId}`,
143
+ );
144
+ return undefined;
145
+ }
146
+ if (typeof record.runId !== "string") return undefined;
147
+ const runId = record.runId;
148
+
149
+ // (2) Required-shape containers. Non-conforming shape → reject the whole
150
+ // checkpoint (schema-invalid state must never hydrate partially).
151
+ if (record.vars !== undefined && !isPlainObject(record.vars)) return undefined;
152
+ const phases = sanitizeStringArray(record.phases);
153
+ if (phases === undefined) return undefined;
154
+ const logs = sanitizeStringArray(record.logs);
155
+ if (logs === undefined) return undefined;
156
+ if (record.currentPhase !== undefined && typeof record.currentPhase !== "string") return undefined;
157
+ const currentPhase: string | undefined = record.currentPhase;
158
+
159
+ // (3) Numeric control accumulators clamp (never trust worker-writable arithmetic).
160
+ const spent = clampNonNegativeNumber(record.spent);
161
+ const agentCount = clampNonNegativeNumber(record.agentCount);
162
+
163
+ // (4) Per-call replay cache: drop schema-invalid / non-producer-reachable entries.
164
+ let completedAgentCalls: Record<string, AgentResult> | undefined;
165
+ if (record.completedAgentCalls !== undefined) {
166
+ if (!isPlainObject(record.completedAgentCalls)) {
167
+ logInternalError(
168
+ "dwf-state-store.load",
169
+ new Error("checkpoint completedAgentCalls is not an object — dropping the replay cache (calls re-execute on resume)"),
170
+ `runId=${runId}`,
171
+ );
172
+ } else {
173
+ completedAgentCalls = {};
174
+ let dropped = 0;
175
+ for (const [callId, entry] of Object.entries(record.completedAgentCalls)) {
176
+ const sanitized = sanitizeAgentResult(entry);
177
+ if (sanitized !== undefined) {
178
+ completedAgentCalls[callId] = sanitized;
179
+ } else {
180
+ dropped++;
181
+ }
182
+ }
183
+ if (dropped > 0) {
184
+ logInternalError(
185
+ "dwf-state-store.load",
186
+ new Error(
187
+ `${dropped} checkpoint agent-call entr${dropped === 1 ? "y" : "ies"} failed hydration validation and ${dropped === 1 ? "was" : "were"} dropped (the call${dropped === 1 ? " re-executes" : "s re-execute"} on resume)`,
188
+ ),
189
+ `runId=${runId}`,
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ // (5) dispatchContext fingerprint: keep only correctly typed fields (the runner
196
+ // compares it against the current dispatch and emits a mismatch event).
197
+ let dispatchContext: DwfCheckpointState["dispatchContext"];
198
+ if (isPlainObject(record.dispatchContext)) {
199
+ dispatchContext = {};
200
+ if (typeof record.dispatchContext.modelOverride === "string") dispatchContext.modelOverride = record.dispatchContext.modelOverride;
201
+ if (typeof record.dispatchContext.team === "string") dispatchContext.team = record.dispatchContext.team;
202
+ }
203
+
204
+ // vars was shape-checked above (undefined or plain object); default {} for legacy absence.
205
+ const vars: Record<string, unknown> = isPlainObject(record.vars) ? record.vars : {};
206
+ return {
207
+ version: DWF_CHECKPOINT_VERSION,
208
+ runId,
209
+ vars,
210
+ phases,
211
+ currentPhase,
212
+ logs,
213
+ spent,
214
+ agentCount,
215
+ ...(completedAgentCalls !== undefined ? { completedAgentCalls } : {}),
216
+ ...(dispatchContext !== undefined ? { dispatchContext } : {}),
217
+ updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(),
218
+ };
219
+ }
220
+
62
221
  /**
63
222
  * DwfStore — atomic CRUD for a single run's DWF checkpoint.
64
223
  *
@@ -83,16 +242,42 @@ export class DwfStore {
83
242
  return `${this.stateRoot}/dwf-checkpoint.json`;
84
243
  }
85
244
 
86
- /** Load the checkpoint for this run's stateRoot. Returns undefined if missing or corrupt (fresh run). */
87
- load(): DwfCheckpointState | undefined {
245
+ /** Load the checkpoint for this stateRoot.
246
+ *
247
+ * w3-dwf-trust (2026-10-29): the checkpoint file lives under the shared repo
248
+ * (<crewRoot>/state/runs/<runId>/dwf-checkpoint.json) where every WRITE-role
249
+ * child worker of the run can also write (same OS uid), so the file can no
250
+ * longer be treated as runner-only ground truth. Hydration now validates at
251
+ * this boundary instead of blind-casting:
252
+ *
253
+ * - `expectedRunId` (passed by dynamic-workflow-runner.ts as manifest.runId)
254
+ * binds the checkpoint to the resuming run — a foreign/stale runId is
255
+ * rejected (→ undefined → fresh run) instead of replayed.
256
+ * - Control accumulators are clamped: `spent`/`agentCount` must be finite
257
+ * non-negative numbers, else reset to 0 (a forged negative `spent` used to
258
+ * inflate budget.remaining() past the dispatched cap and a negative
259
+ * `agentCount` re-armed the SDD-3 G13 agent-call cap).
260
+ * - Schema-invalid containers (non-array phases/logs, non-object vars, a
261
+ * non-string currentPhase) reject the WHOLE checkpoint (fail closed to
262
+ * re-execution).
263
+ * - Hydrated `completedAgentCalls` entries are re-validated against the
264
+ * producer's shape: the runner caches ONLY successful results
265
+ * (`ok === true`, string `text`), so entries failing that verdict
266
+ * allowlist/shape are DROPPED (the call re-executes on resume) and
267
+ * optional fields are stripped when mistyped. Anomalies are logged via
268
+ * logInternalError so quarantines are observable (not silently swallowed).
269
+ *
270
+ * Returns undefined if missing/corrupt/tampered (fresh run). */
271
+ load(expectedRunId?: string): DwfCheckpointState | undefined {
88
272
  const path = this.path;
89
273
  try {
90
274
  if (!existsSync(path)) return undefined;
91
275
  const raw = readFileSync(path, "utf-8");
92
- const parsed = JSON.parse(raw);
93
- // Corrupt-guard: a valid checkpoint must be an object with a string runId
94
- // (mirrors GoalStore.load's typeof parsed.goalId !== "string" check).
95
- if (!parsed || typeof parsed !== "object" || typeof parsed.runId !== "string") return undefined;
276
+ const parsed: unknown = JSON.parse(raw);
277
+ // Corrupt-guard: a valid checkpoint must be a plain JSON object with a
278
+ // string runId (mirrors GoalStore.load's typeof parsed.goalId !== "string"
279
+ // check). Arrays and primitives are rejected outright.
280
+ if (!isPlainObject(parsed) || typeof parsed.runId !== "string") return undefined;
96
281
  // R6-12: version guard, mirroring the recovery/checkpoint.ts runId-guard
97
282
  // style (typed mismatch → treat as not-loadable). A checkpoint from an
98
283
  // unknown FUTURE version is REJECTED (→ undefined → fresh run) rather than
@@ -110,7 +295,8 @@ export class DwfStore {
110
295
  );
111
296
  return undefined;
112
297
  }
113
- return parsed as DwfCheckpointState;
298
+ // w3-dwf-trust: bind + revalidate at the hydration boundary (never blind-cast).
299
+ return sanitizeCheckpoint(parsed, expectedRunId);
114
300
  } catch {
115
301
  return undefined;
116
302
  }
@@ -26,7 +26,8 @@ import { randomBytes } from "node:crypto";
26
26
  import type { TSchema } from "@sinclair/typebox";
27
27
  import type { AgentConfig } from "../../agents/agent-config.ts";
28
28
  import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
29
- import { appendMailboxMessage, readMailbox } from "../../state/coordination/mailbox.ts";
29
+ import { renderInboxMessage } from "../../prompt/prompt-runtime.ts";
30
+ import { appendMailboxMessage, type MailboxMessage, readMailbox } from "../../state/coordination/mailbox.ts";
30
31
  import { appendEvent } from "../../state/event-log/event-log.ts";
31
32
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
32
33
  import type { TeamRunManifest } from "../../state/types.ts";
@@ -383,6 +384,27 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
383
384
  // `while(true){review; if accept break; retry}` loop replay round-1's
384
385
  // verdict forever (stale-verdict hazard, docs/dynamic-workflows.md).
385
386
  const hydratedCallIds = new Set<string>(Object.keys(opts.resumedState?.completedAgentCalls ?? {}));
387
+ // W1b binding (security fix gatherreplies-replyto-only-binding): messageId →
388
+ // expected addressee, recorded when ctx.mail() issues each mail. gatherReplies
389
+ // only accepts a reply row when its broker-authenticated sender (m.from)
390
+ // equals the expected addressee of the mail the reply claims (m.replyTo) — a
391
+ // forged replyTo from any other task cannot enter the reply stream.
392
+ const mailedAddressees = new Map<string, string>();
393
+ const unresolvedMailLookups = new Set<string>();
394
+ // Resolve the expected addressee for a mailed id, falling back to the run
395
+ // outbox ONCE per unknown id (resume path: mails sent by a previous process
396
+ // of the same workflow are not in the in-memory map; their outbox rows are
397
+ // host-written — workers cannot append to the outbox lane — so the recorded
398
+ // `to` is a trusted binding source. Inbox rows are never consulted).
399
+ const expectedReplyAddressee = (messageId: string): string | undefined => {
400
+ const known = mailedAddressees.get(messageId);
401
+ if (known !== undefined) return known;
402
+ if (unresolvedMailLookups.has(messageId)) return undefined;
403
+ unresolvedMailLookups.add(messageId);
404
+ const mailed = readMailbox(manifest, "outbox").find((m) => m.id === messageId && m.from === "dynamic-workflow" && m.to);
405
+ if (mailed?.to) mailedAddressees.set(messageId, mailed.to);
406
+ return mailedAddressees.get(messageId);
407
+ };
386
408
  // round-14 P1-2: frozen budget surface. The closures read wfState.spent so the
387
409
  // object stays live after Object.freeze(ctx). total is a snapshot primitive.
388
410
  const budget = Object.freeze({
@@ -852,18 +874,37 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
852
874
  replyTo: mailOpts?.replyTo,
853
875
  replyDeadline: mailOpts?.replyDeadline,
854
876
  });
877
+ // W1b binding: record the expected addressee so gatherReplies can verify
878
+ // that a reply row's authenticated sender (from) is the task this mail
879
+ // was addressed to (replyTo target ownership).
880
+ mailedAddressees.set(msg.id, to);
855
881
  return msg.id;
856
882
  },
857
883
  async gatherReplies(messageIds: string[], deadlineMs: number): Promise<unknown[]> {
884
+ // W1b binding (security fix gatherreplies-replyto-only-binding): a reply
885
+ // row counts ONLY when (a) it targets the run-level parent lane
886
+ // (to === "parent" — the lane this gather polls), (b) its replyTo is one
887
+ // of the awaited ids, and (c) its broker-authenticated sender (from)
888
+ // equals the addressee recorded for that id at mail time. Duplicate rows
889
+ // from one sender no longer satisfy a multi-id gather (distinct
890
+ // (replyTo, from) pairs are counted toward messageIds.length), and every
891
+ // delivered body is fenced through renderInboxMessage (same seam as
892
+ // worker inbox delivery: control-char strip, closing-tag neutralization,
893
+ // 8192-char cap) before it enters workflow variables.
894
+ const collectBoundReplies = (): MailboxMessage[] =>
895
+ readMailbox(manifest, "inbox").filter((m) => {
896
+ if (!m.replyTo || !messageIds.includes(m.replyTo) || m.to !== "parent") return false;
897
+ return expectedReplyAddressee(m.replyTo) === m.from;
898
+ });
899
+ const distinctReplyPairs = (rows: MailboxMessage[]): number => new Set(rows.map((m) => `${m.replyTo}\u0000${m.from}`)).size;
900
+ const fenceReplyRow = (m: MailboxMessage): MailboxMessage => ({ ...m, body: renderInboxMessage(m) });
858
901
  const deadline = Date.now() + deadlineMs;
859
- while (Date.now() < deadline) {
860
- const inbox = readMailbox(manifest, "inbox");
861
- const got = inbox.filter((m) => m.replyTo && messageIds.includes(m.replyTo));
862
- if (got.length >= messageIds.length) return got;
902
+ for (;;) {
903
+ const got = collectBoundReplies();
904
+ if (distinctReplyPairs(got) >= messageIds.length) return got.map(fenceReplyRow);
905
+ if (Date.now() >= deadline || ctxSignal.aborted) return got.map(fenceReplyRow);
863
906
  await new Promise((r) => setTimeout(r, 500));
864
- if (ctxSignal.aborted) return inbox.filter((m) => m.replyTo && messageIds.includes(m.replyTo));
865
907
  }
866
- return readMailbox(manifest, "inbox").filter((m) => m.replyTo && messageIds.includes(m.replyTo));
867
908
  },
868
909
  renderTemplate(name: string, vars: Record<string, string>): unknown {
869
910
  return renderPlanTemplate(name, vars);