pi-crew 0.11.50 → 0.11.52

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 (68) hide show
  1. package/CHANGELOG.md +143 -0
  2. package/dist/index.mjs +552 -545
  3. package/package.json +1 -1
  4. package/src/agents/agent-config.ts +58 -1
  5. package/src/config/config-validation.ts +18 -0
  6. package/src/config/env-allowlist-registry.ts +4 -0
  7. package/src/config/env-vars.ts +4 -0
  8. package/src/config/sanitize-project-config.ts +55 -0
  9. package/src/extension/management.ts +9 -0
  10. package/src/extension/registration/lifecycle-handlers.ts +28 -0
  11. package/src/extension/registration/subagent-tools.ts +5 -1
  12. package/src/extension/run-import.ts +40 -6
  13. package/src/extension/team-tool/api/agent-control.ts +49 -0
  14. package/src/extension/team-tool/api/heartbeat.ts +48 -1
  15. package/src/extension/team-tool/api/mailbox.ts +81 -2
  16. package/src/extension/team-tool/api/read.ts +22 -0
  17. package/src/extension/team-tool/api/task-claims.ts +134 -3
  18. package/src/extension/team-tool/cancel.ts +15 -1
  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/background-runner.ts +101 -81
  27. package/src/runtime/broker/crew-broker-child.ts +32 -0
  28. package/src/runtime/broker/crew-broker-tokens.ts +24 -13
  29. package/src/runtime/broker/crew-broker.ts +12 -6
  30. package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
  31. package/src/runtime/child-pi/child-pi-spawn.ts +17 -0
  32. package/src/runtime/child-pi/child-pi-steering.ts +8 -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/foreground-control.ts +95 -1
  38. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
  39. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
  40. package/src/runtime/heartbeat/heartbeat-watcher.ts +32 -11
  41. package/src/runtime/live-session/live-session-runtime.ts +78 -3
  42. package/src/runtime/mcp-proxy.ts +65 -6
  43. package/src/runtime/model/pi-args.ts +16 -15
  44. package/src/runtime/plan-replan.ts +4 -1
  45. package/src/runtime/task-runner/child-executor.ts +34 -7
  46. package/src/runtime/task-runner/hybrid-tier.ts +17 -0
  47. package/src/runtime/task-runner/live-executor.ts +3 -0
  48. package/src/runtime/task-runner/post-execution.ts +5 -0
  49. package/src/runtime/task-runner/pre-execution.ts +13 -6
  50. package/src/runtime/task-runner.ts +14 -0
  51. package/src/runtime/team-runner.ts +26 -0
  52. package/src/runtime/verification/verification-worktree.ts +46 -29
  53. package/src/runtime/verification/verify-gate-memo.ts +211 -32
  54. package/src/runtime/verification/verify-gate.ts +266 -14
  55. package/src/runtime/workspace-lock.ts +95 -21
  56. package/src/schema/config-schema.ts +12 -2
  57. package/src/state/coordination/locks.ts +86 -19
  58. package/src/state/coordination/mailbox.ts +263 -22
  59. package/src/state/stores/manifest-io.ts +206 -7
  60. package/src/state/stores/state-store.ts +44 -15
  61. package/src/state/types.ts +9 -0
  62. package/src/ui/inline-panel/agent-transcript.ts +151 -11
  63. package/src/ui/settings-overlay.ts +8 -3
  64. package/src/ui/theme-discovery.ts +14 -2
  65. package/src/ui/tool-renderers/index.ts +26 -6
  66. package/src/utils/safe-terminal-text.ts +60 -0
  67. package/src/worktree/git-hardening.ts +78 -1
  68. package/src/worktree/worktree-manager.ts +34 -4
@@ -1,7 +1,8 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { getCrewEnv } from "../config/env-vars.ts";
3
4
  import { atomicWriteFile } from "../state/atomic-write.ts";
4
- import { appendEvent } from "../state/event-log/event-log.ts";
5
+ import { appendEvent, appendEventBuffered } from "../state/event-log/event-log.ts";
5
6
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
6
7
  import { logInternalError } from "../utils/internal-error.ts";
7
8
  import { sleepSync } from "../utils/sleep.ts";
@@ -204,3 +205,96 @@ export function writeForegroundInterruptRequest(
204
205
  releaseLock();
205
206
  }
206
207
  }
208
+
209
+ // ═══════════════════════════════════════════════════════════════════════════
210
+ // FIX-1 (live catch, run team_20261030044630_3935473bc4886682): the
211
+ // foreground-control interrupt CONSUMER. The guard used to live ONLY inside
212
+ // background-runner's detached main(); runs executed INLINE in the owner
213
+ // session process (executeBackgroundRun core — also the PI_CREW_TEST_ASYNC_INLINE
214
+ // seam) never polled foreground-control.json, so cross-session cancels sat
215
+ // acknowledged:false forever (two requests in that run) while the worker ran
216
+ // ≥75s post-cancel with fresh heartbeats. Colocated with the PRODUCER
217
+ // (writeForegroundInterruptRequest above) so the control-file contract has
218
+ // one home.
219
+ //
220
+ // Polling/ack semantics carried over byte-identically from the previous
221
+ // inline guard in background-runner.ts: existsSync+readFileSync+JSON.Parse
222
+ // per tick, interval PI_CREW_INTERRUPT_GUARD_INTERVAL_MS (default 1000ms),
223
+ // synchronous atomic ack write, once-per-request gate (defense-in-depth when
224
+ // the ack write fails), appendEventBuffered("async.interrupt_detected"),
225
+ // interval.unref().
226
+ //
227
+ // What an interrupt DOES is host-scoped via onInterrupt:
228
+ // - detached runner (background-runner.startInterruptGuard): process-wide
229
+ // sweep — terminateActiveChildPiProcesses + stopParentGuard + exitCode 130.
230
+ // - inline core (executeBackgroundRun with no host signal): abort ONLY this
231
+ // run's controller. NEVER sweep: the owner session process may host
232
+ // sibling runs whose child-pi processes would be murdered by a global
233
+ // terminateActiveChildPiProcesses() call.
234
+ // ═══════════════════════════════════════════════════════════════════════════
235
+
236
+ export interface RunInterruptGuardOptions {
237
+ manifest: { runId: string; stateRoot: string; eventsPath: string };
238
+ /**
239
+ * Host-scoped interrupt action. Runs ONCE per unacknowledged request,
240
+ * after the synchronous ack write. Keep it synchronous: this is a
241
+ * setInterval polling callback — it cannot await.
242
+ */
243
+ onInterrupt: () => void;
244
+ /** Message for the async.interrupt_detected event (hosts describe their own follow-up). */
245
+ eventMessage?: string;
246
+ }
247
+
248
+ export function startRunInterruptGuard(opts: RunInterruptGuardOptions): () => void {
249
+ const controlPath = path.join(opts.manifest.stateRoot, "foreground-control.json");
250
+ // PERF (2026-08-24, carried over verbatim): default 1000ms. Each tick does
251
+ // existsSync + readFileSync + JSON.parse for the whole run, and the guard
252
+ // is best-effort interrupt LATENCY, not a correctness deadline — a late
253
+ // trigger merely delays the SIGTERM (children are SIGKILLed after
254
+ // HARD_KILL_MS=3s regardless) and no state machine aborts on a deadline
255
+ // when the guard fires late. Nothing downstream requires a tighter cadence.
256
+ const interruptGuardInterval = Number(getCrewEnv("PI_CREW_INTERRUPT_GUARD_INTERVAL_MS")) || 1000;
257
+ // RT-4 FIX (carried over verbatim): local gate so the interrupt body runs
258
+ // only once per request. The ack write stops the re-fire; this gate is
259
+ // defense-in-depth if the ack write fails (e.g. transient fs error).
260
+ let interruptHandled = false;
261
+ const interval = setInterval(() => {
262
+ try {
263
+ if (!fs.existsSync(controlPath)) return;
264
+ const parsed = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as {
265
+ requests?: Array<{ type: string; acknowledged?: boolean }>;
266
+ };
267
+ const last = parsed.requests?.at(-1);
268
+ if (last?.type === "interrupt" && last?.acknowledged !== true) {
269
+ if (interruptHandled) return;
270
+ interruptHandled = true;
271
+ // RT-4 FIX: write acknowledged:true back SYNCHRONOUSLY so the next
272
+ // tick does not re-fire. Sync because a polling callback cannot await.
273
+ try {
274
+ const reqs = parsed.requests ?? [];
275
+ if (reqs.length > 0) {
276
+ reqs[reqs.length - 1].acknowledged = true;
277
+ atomicWriteFile(controlPath, JSON.stringify(parsed, null, 2));
278
+ }
279
+ } catch {
280
+ /* best-effort ack — interruptHandled gate prevents re-fire */
281
+ }
282
+ appendEventBuffered(opts.manifest.eventsPath, {
283
+ type: "async.interrupt_detected",
284
+ runId: opts.manifest.runId,
285
+ message: opts.eventMessage ?? "Background runner detected foreground interrupt — killing child processes and exiting.",
286
+ }).catch((e) => logInternalError("interrupt-guard.buffered", e, "type=async.interrupt_detected"));
287
+ opts.onInterrupt();
288
+ // Cosmetic (verifier-flagged, live catch follow-up): an ALREADY-ACKED
289
+ // interrupt must not fall through to this branch — it used to warn
290
+ // "Ignoring unknown ... interrupt" every tick until guard stop.
291
+ } else if (last && !(last.type === "interrupt" && last.acknowledged === true)) {
292
+ console.warn(`[background-runner] Ignoring unknown foreground control request: ${last.type}`);
293
+ }
294
+ } catch {
295
+ /* ignore read/parse errors */
296
+ }
297
+ }, interruptGuardInterval);
298
+ interval.unref();
299
+ return () => clearInterval(interval);
300
+ }
@@ -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);
@@ -17,12 +17,14 @@
17
17
  * See docs/dynamic-workflows.md for the full threat model.
18
18
  */
19
19
 
20
+ import { createHash } from "node:crypto";
20
21
  import { readFileSync } from "node:fs";
21
22
  import { createRequire } from "node:module";
22
23
  import { join } from "node:path";
23
24
  import { getCrewEnv } from "../../config/env-vars.ts";
24
25
  import { appendEventAsync } from "../../state/event-log/event-log.ts";
25
26
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
27
+ import { saveRunManifestAsync } from "../../state/stores/state-store.ts";
26
28
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
27
29
  import { logInternalError } from "../../utils/internal-error.ts";
28
30
  import { packageRoot, projectCrewRoot, userPiRoot } from "../../utils/paths.ts";
@@ -177,26 +179,97 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
177
179
  const eventsPath = manifest.eventsPath;
178
180
  const scriptPath = resolveScriptPath(workflow, manifest.cwd);
179
181
 
180
- // F-01: Trust gate — project-sourced .dwf.ts scripts have FULL Node access (process,
182
+ // w3-dwf-trust: dispatch-time pin stamping (single point). The FIRST entry to the
183
+ // runner for a run IS the dispatch — stamp manifest.dynamicScriptPin with the exact
184
+ // resolved script path + sha256 of the CURRENT bytes and persist before any module
185
+ // load. Both resume arms funnel back through runDynamicWorkflow, so every later
186
+ // entry hits the pin-mismatch verification below: a plant that swaps the file
187
+ // between dispatch and resume is refused instead of executing swapped bytes.
188
+ // Absent pin (legacy manifests) gets stamped on first resume — backward compatible.
189
+ if (manifest.dynamicScriptPin === undefined) {
190
+ let sha256: string;
191
+ try {
192
+ sha256 = createHash("sha256").update(readFileSync(scriptPath)).digest("hex");
193
+ } catch (error) {
194
+ logInternalError("dynamic-workflow-runner: pin stamp hash failed", error);
195
+ throw new Error(
196
+ `Dynamic workflow script '${scriptPath}' could not be read for dispatch-time pinning — refusing to execute an unhashable script.`,
197
+ );
198
+ }
199
+ manifest.dynamicScriptPin = { scriptPath, sha256 };
200
+ try {
201
+ await saveRunManifestAsync(manifest);
202
+ } catch (error) {
203
+ // Pin not durable → verification cannot be trusted later. Fail closed.
204
+ logInternalError("dynamic-workflow-runner: pin stamp persist failed", error);
205
+ throw new Error(`Dynamic workflow dispatch failed to persist the script pin — refusing to execute without a durable pin.`);
206
+ }
207
+ }
208
+
209
+ // F-01: Trust gate — project/user-sourced .dwf.ts scripts have FULL Node access (process,
181
210
  // require, fs, child_process). Cloning a hostile repo and running its workflow = arbitrary
182
- // code execution. Default-deny project workflows unless the user explicitly opts in via
183
- // PI_CREW_TRUST_PROJECT_DWF=1. Builtin and user workflows proceed without restriction.
211
+ // code execution; default-deny project workflows unless the user explicitly opts in via
212
+ // PI_CREW_TRUST_PROJECT_DWF=1.
213
+ // w3-dwf-trust (2026-10-29): the USER tier gets the SAME default-deny via
214
+ // PI_CREW_TRUST_USER_DWF=1. ~/.pi/agent/workflows is writable by every WRITE-role
215
+ // child worker (same OS uid, bash granted by role-tools), so directory location alone
216
+ // is not human provenance: a worker-planted user-tier .dwf.ts previously executed in
217
+ // this HOST process (parent env, in-memory broker state) and silently shadowed
218
+ // same-named builtin/project workflows via the allWorkflows merge order. Builtin
219
+ // (shipped, package-owned) workflows proceed without restriction.
184
220
  // U2 (2026-10-10): awaited appendEventAsync — the 2026-09-10 M2a revert
185
221
  // was required by BUFFERED (20ms-delayed) writes; awaiting the direct async
186
222
  // append keeps the read-your-writes contract (resume flow, dwf-setresult
187
223
  // tests, status display all read events only after runDynamicWorkflow
188
224
  // resolves) while dropping the sleepSync event-loop block.
189
- if (workflow.source === "project" && getCrewEnv("PI_CREW_TRUST_PROJECT_DWF") !== "1") {
225
+ const trustEnv: "PI_CREW_TRUST_PROJECT_DWF" | "PI_CREW_TRUST_USER_DWF" | undefined =
226
+ workflow.source === "project" ? "PI_CREW_TRUST_PROJECT_DWF" : workflow.source === "user" ? "PI_CREW_TRUST_USER_DWF" : undefined;
227
+ if (trustEnv !== undefined && getCrewEnv(trustEnv) !== "1") {
190
228
  await appendEventAsync(eventsPath, {
191
229
  type: "dwf.trust_denied",
192
230
  runId: manifest.runId,
193
231
  data: { workflow: workflow.name, source: workflow.source, script: scriptPath },
194
232
  });
195
233
  throw new Error(
196
- `Project dynamic workflow requires explicit trust. Set PI_CREW_TRUST_PROJECT_DWF=1 to allow execution of project .dwf.ts scripts.`,
234
+ workflow.source === "user"
235
+ ? `User dynamic workflow requires explicit trust. Set PI_CREW_TRUST_USER_DWF=1 to allow execution of user .dwf.ts scripts.`
236
+ : `Project dynamic workflow requires explicit trust. Set PI_CREW_TRUST_PROJECT_DWF=1 to allow execution of project .dwf.ts scripts.`,
197
237
  );
198
238
  }
199
239
 
240
+ // w3-dwf-trust: dispatch-time script pin. When the dispatcher stamped
241
+ // manifest.dynamicScriptPin, re-hash the script bytes before EVERY load — fresh
242
+ // dispatch AND both resume arms funnel through runDynamicWorkflow — so a plant
243
+ // that swaps the file between dispatch and resume cannot execute. Optional/absent
244
+ // on manifests dispatched before this field existed (and on dispatchers that do
245
+ // not stamp it yet) → no pin verification (backward compatible).
246
+ const pin = manifest.dynamicScriptPin;
247
+ if (pin !== undefined) {
248
+ let actualSha256: string | undefined;
249
+ try {
250
+ actualSha256 = createHash("sha256").update(readFileSync(scriptPath)).digest("hex");
251
+ } catch {
252
+ // Unreadable/missing script cannot match its pin — treat as mismatch.
253
+ actualSha256 = undefined;
254
+ }
255
+ if (pin.scriptPath !== scriptPath || actualSha256 === undefined || pin.sha256 !== actualSha256) {
256
+ await appendEventAsync(eventsPath, {
257
+ type: "dwf.trust_denied",
258
+ runId: manifest.runId,
259
+ data: {
260
+ workflow: workflow.name,
261
+ source: workflow.source,
262
+ script: scriptPath,
263
+ reason: "script-pin-mismatch",
264
+ ...(actualSha256 !== undefined ? { expectedSha256: pin.sha256, actualSha256 } : {}),
265
+ },
266
+ });
267
+ throw new Error(
268
+ `Dynamic workflow script does not match the dispatch-time pin (path/sha256) — refusing to load a possibly swapped script. Re-dispatch the run to re-pin the current script bytes.`,
269
+ );
270
+ }
271
+ }
272
+
200
273
  await appendEventAsync(eventsPath, {
201
274
  type: "dwf.started",
202
275
  runId: manifest.runId,
@@ -208,7 +281,10 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
208
281
  // <stateRoot>/dwf-checkpoint.json (no double-nesting). A missing checkpoint (fresh run)
209
282
  // yields undefined — makeWorkflowCtx starts with empty defaults (backward compatible).
210
283
  const dwfStore = new DwfStore(manifest.stateRoot);
211
- const resumedState = dwfStore.load();
284
+ // w3-dwf-trust: bind the checkpoint to THIS run — a checkpoint whose runId
285
+ // differs (stale/planted/enumerated under .crew/state/runs/) is rejected and
286
+ // the run restarts fresh instead of replaying worker-writable state.
287
+ const resumedState = dwfStore.load(manifest.runId);
212
288
  if (resumedState) {
213
289
  // R14-C3 (wave 7): fingerprint check — a resume that changes the dispatch
214
290
  // context (modelOverride/team) still replays the completed-call cache, but
@@ -109,22 +109,43 @@ export class HeartbeatWatcher {
109
109
  activeKeys.add(key);
110
110
  this.lastSeen.set(key, now);
111
111
 
112
- // Check heartbeat staleness with lastActivityAt fallback
113
- let elapsed = heartbeatAgeMs(task.heartbeat, now);
112
+ // FIX-2 (live catch, run team_20261030044630_3935473bc4886682):
113
+ // attempt-clamp. handleRetry re-queues tasks WITHOUT resetting
114
+ // liveness fields, so a retry attempt inherited attempt-1's stale
115
+ // heartbeat/agentProgress timestamps and this watcher fired
116
+ // heartbeat_dead 3× for a worker that was ALIVE (fresh heartbeats,
117
+ // active tool calls). Evidence predating the CURRENT attempt's
118
+ // task.startedAt is meaningless and must be ignored; startedAt is
119
+ // reset per attempt at dispatch (child-executor attemptStartedAt),
120
+ // so it is the authoritative attempt boundary.
121
+ const startedAtMs = task.startedAt !== undefined ? Date.parse(task.startedAt) : Number.NaN;
122
+ const attemptStartMs = Number.isFinite(startedAtMs) ? startedAtMs : undefined;
123
+ const evidenceUsable = (iso: string | undefined): boolean => {
124
+ if (iso === undefined) return false;
125
+ const ms = Date.parse(iso);
126
+ return Number.isFinite(ms) && (attemptStartMs === undefined || ms >= attemptStartMs);
127
+ };
128
+ // Check heartbeat staleness with lastActivityAt fallback (evidence
129
+ // older than startedAt is inadmissible — see clamp above).
130
+ let elapsed = evidenceUsable(task.heartbeat?.lastSeenAt) ? heartbeatAgeMs(task.heartbeat, now) : Number.POSITIVE_INFINITY;
114
131
  // PR #6 partial: use lastActivityAt as fallback when heartbeat is stale
115
132
  // If heartbeat is stale but lastActivityAt is fresher, use activity age instead.
116
133
  // This prevents false-positive dead detection for live-session tasks during long operations.
117
- if (task.agentProgress?.lastActivityAt) {
118
- const activityAt = new Date(task.agentProgress.lastActivityAt).getTime();
119
- if (Number.isFinite(activityAt)) {
120
- const activityAge = now - activityAt;
121
- // Use activity age if it's fresher than heartbeat age
122
- // (no upper bound - if agent has recent activity, trust it even if old)
123
- if (activityAge < elapsed) {
124
- elapsed = activityAge;
125
- }
134
+ if (evidenceUsable(task.agentProgress?.lastActivityAt)) {
135
+ const activityAge = now - new Date(task.agentProgress!.lastActivityAt!).getTime();
136
+ // Use activity age if it's fresher than heartbeat age
137
+ // (no upper bound - if agent has recent activity, trust it even if old)
138
+ if (activityAge < elapsed) {
139
+ elapsed = activityAge;
126
140
  }
127
141
  }
142
+ // No usable liveness evidence at all → compute elapsed from the
143
+ // attempt's own start: a task younger than deadMs can never be dead
144
+ // (previously a missing heartbeat meant elapsed=∞ → instant "dead").
145
+ // Tasks without startedAt keep the legacy ∞ (unclassifiable).
146
+ if (!Number.isFinite(elapsed) && attemptStartMs !== undefined) {
147
+ elapsed = Math.max(0, now - attemptStartMs);
148
+ }
128
149
  // PID liveness gate: if the worker process is still alive, downgrade
129
150
  // "dead" to "stale". This prevents false positives when the LLM spends
130
151
  // a long time generating a response (>5 min) without tool calls.
@@ -8,7 +8,14 @@ import { redactSecrets } from "../../utils/redaction.ts";
8
8
  import { BoundedTail } from "../compaction/compact-stages/bounded-tail.ts";
9
9
  import { createIrcTool } from "../custom-tools/irc-tool.ts";
10
10
  import { createSubmitResultTool } from "../custom-tools/submit-result-tool.ts";
11
- import { g2EnforcementDegradationReason, mcpPermittedForRole, shouldFailClosedMcpEnforcement, stripMcpExtensions } from "../mcp-proxy.ts";
11
+ import {
12
+ foreignMcpExtensionsAgainstAllowlist,
13
+ g2EnforcementDegradationReason,
14
+ McpForeignExtensionError,
15
+ mcpPermittedForRole,
16
+ shouldFailClosedMcpEnforcement,
17
+ stripMcpExtensions,
18
+ } from "../mcp-proxy.ts";
12
19
  import { buildConfiguredModelRouting, modelRefToString, warnOutOfScopeSoft } from "../model/model-fallback.ts";
13
20
  import { isLiveSessionRuntimeAvailable } from "../model/runtime-resolver.ts";
14
21
  import { awaitRuntimeWarmup } from "../model/runtime-warmup.ts";
@@ -360,13 +367,42 @@ export function collectForeignExtensions(session: LiveSessionLike | undefined, a
360
367
  const runner = session?.extensionRunner;
361
368
  if (typeof runner?.getExtensionPaths !== "function") return [];
362
369
  try {
363
- const allowed = new Set(allowedPaths);
364
- return runner.getExtensionPaths().filter((extensionPath: string) => !allowed.has(extensionPath));
370
+ // N1 (security-audit run-1): the allowlist check itself has exactly ONE
371
+ // implementation — mcp-proxy.ts `foreignMcpExtensionsAgainstAllowlist` —
372
+ // so the fast-path assert and the legacy-loader-route assert below share
373
+ // identical semantics (set membership against allowedLiveExtensionPaths).
374
+ return foreignMcpExtensionsAgainstAllowlist(runner.getExtensionPaths(), allowedPaths);
365
375
  } catch {
366
376
  return [];
367
377
  }
368
378
  }
369
379
 
380
+ /**
381
+ * N1 (security-audit run-1): the post-creation assert DECISION for the LEGACY
382
+ * `DefaultResourceLoader` route (the `!sharedServicesAvailable` branch) — the
383
+ * same backstop the shared-services fast path's own assert gives it, with
384
+ * identical allowlist semantics (`collectForeignExtensions` +
385
+ * `allowedLiveExtensionPaths`). The loader-level `stripMcpExtensions` control
386
+ * is a NAME-based denylist, so a differently-named MCP adapter survives it;
387
+ * for a NON-MCP-permitted role the allowlist is empty, hence ANY extension
388
+ * that still loaded is a violation.
389
+ *
390
+ * Armed ONLY for non-MCP-permitted roles on the legacy route: permitted roles
391
+ * keep their extensions there (no strip is armed for them), and the fast path
392
+ * owns its own assert (pass `sharedServices: true` to disarm this one). The
393
+ * no-loader degradation case never reaches an assert — it already failed
394
+ * closed earlier via `g2EnforcementDegradationReason` +
395
+ * `shouldFailClosedMcpEnforcement`. Returns the offending foreign extension
396
+ * paths; empty = pass.
397
+ */
398
+ export function legacyLoaderMcpAllowlistViolation(
399
+ session: LiveSessionLike | undefined,
400
+ route: { sharedServices: boolean; mcpPermitted: boolean },
401
+ ): string[] {
402
+ if (route.sharedServices || route.mcpPermitted) return [];
403
+ return collectForeignExtensions(session, allowedLiveExtensionPaths(false));
404
+ }
405
+
370
406
  function appendTranscript(filePath: string | undefined, event: unknown): void {
371
407
  if (!filePath) return;
372
408
  // Task 26 (2026-08-24): serialize + redact at queue time, then route
@@ -971,6 +1007,45 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
971
1007
  );
972
1008
  }
973
1009
  }
1010
+ // N1 (security-audit run-1, w9-n1): allowlist-shaped post-creation assert
1011
+ // for the LEGACY DefaultResourceLoader route — the same backstop the
1012
+ // shared-services fast path gets above, with identical semantics (single
1013
+ // mcp-proxy check via collectForeignExtensions). stripMcpExtensions is a
1014
+ // NAME-based denylist, so a differently-named MCP adapter (e.g.
1015
+ // 'mcp-bridge') survives it; any extension that still loaded for a
1016
+ // NON-MCP-permitted role is foreign (its allowlist is empty) and must
1017
+ // fail the task instead of silently loading and connecting its servers.
1018
+ // NOT armed for permitted roles (the legacy route keeps their extensions)
1019
+ // and unreachable for the no-loader degradation path (that already failed
1020
+ // closed above via g2EnforcementDegradationReason).
1021
+ const legacyForeignExtensions = legacyLoaderMcpAllowlistViolation(session, {
1022
+ sharedServices: Boolean(sharedBundle),
1023
+ mcpPermitted,
1024
+ });
1025
+ if (legacyForeignExtensions.length > 0) {
1026
+ const failClosedCode = "mcp-allowlist-fail-closed";
1027
+ const legacyAllowedPaths = allowedLiveExtensionPaths(false);
1028
+ appendEventFireAndForget(input.manifest.eventsPath, {
1029
+ type: "live-session.foreign_extension_blocked",
1030
+ runId: input.manifest.runId,
1031
+ taskId: input.task.id,
1032
+ message: `G2 MCP policy ABORTED live-session spawn for role "${input.task.role ?? "unknown"}" (${failClosedCode}): child session (legacy loader route) loaded extension(s) ${legacyForeignExtensions.join(
1033
+ ", ",
1034
+ )} not sanctioned for a non-MCP-permitted role — failing closed instead of silently running with them.`,
1035
+ data: {
1036
+ foreignExtensions: legacyForeignExtensions,
1037
+ allowed: legacyAllowedPaths,
1038
+ route: "legacy-loader",
1039
+ code: failClosedCode,
1040
+ role: input.task.role ?? null,
1041
+ },
1042
+ });
1043
+ throw new McpForeignExtensionError({
1044
+ foreignExtensions: legacyForeignExtensions,
1045
+ allowedExtensions: legacyAllowedPaths,
1046
+ role: input.task.role,
1047
+ });
1048
+ }
974
1049
  // U3: register the test-only provider override (faux E2E seam) before
975
1050
  // anything streams. No-op in production (modelProviderOverride stays
976
1051
  // undefined there).
@@ -144,12 +144,21 @@ export function mcpPermittedForRole(role: string | undefined): boolean {
144
144
  * "replaces the built-in MCP support"). Both must be dropped to actually
145
145
  * deny MCP; a path-segment match keeps npm-installed and file-path forms.
146
146
  *
147
- * KNOWN LIMITATION (security-review follow-up): this is a NAME-based
148
- * denylist. A third-party MCP adapter that registers `/mcp` under a
149
- * different name is NOT stripped and would re-enable discovery for
150
- * non-permitted roles on the live-session path. An allowlist-shaped
151
- * extension filter is the root fix — tracked as a follow-up work item
152
- * (SDD-2 §13, G2 hardening backlog).
147
+ * KNOWN LIMITATION (name-denylist gap — CLOSED at the assert layer,
148
+ * security-audit run-1 N1): this remains a NAME-based denylist, so a
149
+ * third-party MCP adapter that registers `/mcp` under a different name
150
+ * (e.g. `mcp-bridge`) is NOT stripped here and would re-enable discovery
151
+ * for non-permitted roles. The root allowlist fix is now layered on top:
152
+ * after the child session is created on the legacy `DefaultResourceLoader`
153
+ * route, the live-session runtime runs the same-shaped post-creation assert
154
+ * the shared-services fast path runs (`foreignMcpExtensionsAgainstAllowlist`
155
+ * below, fed by `allowedLiveExtensionPaths`) and FAILS the task for a
156
+ * non-MCP-permitted role when ANY extension survived the strip — a
157
+ * differently-named adapter now hard-fails (`McpForeignExtensionError`)
158
+ * instead of silently loading and connecting its servers. Permitted roles
159
+ * are unaffected (the strip is not armed for them); the no-loader
160
+ * degradation path already fails closed via `g2EnforcementDegradationReason`
161
+ * + `shouldFailClosedMcpEnforcement`.
153
162
  */
154
163
  export function isMcpExtensionPath(extensionPath: string): boolean {
155
164
  if (extensionPath === "builtin:mcp") return true;
@@ -216,3 +225,53 @@ export function stripMcpExtensions(base: LoadedExtensionsShape): LoadedExtension
216
225
  export function shouldFailClosedMcpEnforcement(mcpPermitted: boolean, degradationReason: string | undefined): boolean {
217
226
  return degradationReason !== undefined && !mcpPermitted;
218
227
  }
228
+
229
+ /**
230
+ * N1 (security-audit run-1): the allowlist-shaped control the KNOWN
231
+ * LIMITATION block above called the root fix — given the extension paths a
232
+ * child session ACTUALLY loaded and the sanctioned allowlist, return the
233
+ * foreign/offending entries.
234
+ *
235
+ * Semantics are MIRRORED EXACTLY from the shared-services fast path's
236
+ * post-creation assert in live-session-runtime.ts (`allowedLiveExtensionPaths`
237
+ * + `collectForeignExtensions`): an extension is foreign iff its path is not
238
+ * a member of the allowlist — no name heuristics, no substring matching. For
239
+ * a non-MCP-permitted role the allowlist is empty, so ANY loaded extension
240
+ * (including a differently-named MCP adapter that survives
241
+ * `stripMcpExtensions`) is foreign; for a permitted role the allowlist is
242
+ * exactly `builtin:mcp`. `collectForeignExtensions` delegates here so both
243
+ * live-session routes share ONE implementation of these semantics.
244
+ */
245
+ export function foreignMcpExtensionsAgainstAllowlist(
246
+ loadedExtensionPaths: readonly string[],
247
+ allowedExtensionPaths: readonly string[],
248
+ ): string[] {
249
+ const allowed = new Set(allowedExtensionPaths);
250
+ return loadedExtensionPaths.filter((extensionPath) => !allowed.has(extensionPath));
251
+ }
252
+
253
+ /**
254
+ * N1 (security-audit run-1, fail-closed): thrown by the live-session
255
+ * runtime's post-creation assert on the LEGACY `DefaultResourceLoader` route
256
+ * when the child session loaded extension(s) not sanctioned for a
257
+ * non-MCP-permitted role (e.g. a differently-named MCP adapter that survived
258
+ * `stripMcpExtensions`). Mirrors `LiveSessionEmbeddedEnforcementError`'s
259
+ * pattern: plain fields assigned in the constructor body (strip-types
260
+ * rejects TS parameter properties), a stamped `name`, and a `[<code>]`-
261
+ * prefixed message consistent with the G2 fail-closed style.
262
+ */
263
+ export class McpForeignExtensionError extends Error {
264
+ readonly foreignExtensions: string[];
265
+ readonly allowedExtensions: string[];
266
+
267
+ constructor(input: { foreignExtensions: readonly string[]; allowedExtensions: readonly string[]; role: string | undefined }) {
268
+ super(
269
+ `[mcp-allowlist-fail-closed] Live-session spawn refused for role "${input.role ?? "unknown"}": child session loaded extension(s) not sanctioned for a non-MCP-permitted role: ${input.foreignExtensions.join(
270
+ ", ",
271
+ )} (allowed: ${input.allowedExtensions.length === 0 ? "none" : input.allowedExtensions.join(", ")}) — failing closed instead of silently running with them.`,
272
+ );
273
+ this.foreignExtensions = [...input.foreignExtensions];
274
+ this.allowedExtensions = [...input.allowedExtensions];
275
+ this.name = "McpForeignExtensionError";
276
+ }
277
+ }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig } from "../../agents/agent-config.ts";
5
+ import { resolveToolPolicy } from "../../agents/agent-config.ts";
5
6
  import { getCrewEnv, getCrewEnvBool } from "../../config/env-vars.ts";
6
7
  import { atomicWriteFile } from "../../state/atomic-write.ts";
7
8
  import { hasRunStateLayout, packageRoot, userPiRoot } from "../../utils/paths.ts";
@@ -383,19 +384,16 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
383
384
  if (input.agent.disableTools === true) {
384
385
  args.push("--no-tools"); // capability-locked agents giữ hành vi (goal-judge)
385
386
  } else {
386
- // AgentConfig.tools is string[] (parseToolsField normalizes the
387
- // frontmatter CSV), but inline/runtime-constructed agents may still
388
- // carry the raw CSV string — normalize both.
389
- const rawTools: unknown = input.agent.tools;
390
- const declared =
391
- typeof rawTools === "string"
392
- ? rawTools
393
- .split(",")
394
- .map((t) => t.trim())
395
- .filter(Boolean)
396
- : Array.isArray(rawTools)
397
- ? rawTools.map((t) => String(t).trim()).filter(Boolean)
398
- : [];
387
+ // S-7 (w4b security fix): the tool surface is now emitted from the unified
388
+ // resolveToolPolicy — the SAME seam live-session's filterActiveTools
389
+ // consumes — instead of the raw frontmatter declaration. This brings the
390
+ // source-aware allowlist precedence, the additive role denylist, and the
391
+ // read_only execution-tool floor (bash/edit/write stripped + excluded for
392
+ // default-deny/custom read-only roles regardless of frontmatter) onto the
393
+ // child spawn path. Role comes from input.role (the teamRole / spawn role);
394
+ // resolveToolPolicy normalizes the raw CSV-string form internally.
395
+ const policy = resolveToolPolicy(input.agent, input.role ?? input.agent.name);
396
+ const declared = policy.tools ?? [];
399
397
  if (declared.length > 0) {
400
398
  // L2-3 (pi 1.1.0 `+name` modifier syntax) — OPT-IN via
401
399
  // PI_CREW_TOOLS_MODIFIERS=1, default OFF. Decision rationale (Wave A,
@@ -421,7 +419,10 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
421
419
  // buildSubagentToolAllowlist của pi-interactive-subagents index.ts:809-811).
422
420
  // B2 (fix round 1): `disallowedTools:` frontmatter is a
423
421
  // declaration-driven denylist (opt-in like `tools:`) — NOT the
424
- // role-based policy D5 removed.
422
+ // role-based policy D5 removed. S-7 (w4b): the set now starts from
423
+ // resolveToolPolicy's excludeTools — which ALREADY merges
424
+ // agent.disallowedTools + the additive role denylist + the read_only
425
+ // execution-tool floor — so every policy cut reaches the child argv.
425
426
  // U2-lite (pi 1.0.4 `--tools` semantics drift): on 1.0.4, `--tools` no
426
427
  // longer cuts MCP — ambient MCP servers stay connected on parity
427
428
  // (NON-hermetic) spawns. Fold `mcp__*` into the exclude list to restore
@@ -437,7 +438,7 @@ export function buildPiWorkerArgs(input: BuildPiWorkerArgsInput): BuildPiWorkerA
437
438
  // `--no-tools` already locks the whole tool surface, an extra cut would
438
439
  // be dead weight. Surface TUI panes strip argv flags after build
439
440
  // (child-pi.ts stripHeadlessOnlyFlags), so this cut never reaches them.
440
- const disallowed = new Set((input.agent.disallowedTools ?? []).map((t) => t.trim()).filter(Boolean));
441
+ const disallowed = new Set((policy.excludeTools ?? []).map((t) => t.trim()).filter(Boolean));
441
442
  const paritySpawn = !resolveHermeticWorkers(input.hermeticWorkers);
442
443
  if (paritySpawn) disallowed.add("mcp__" + "*");
443
444
  if (disallowed.size > 0) args.push("--exclude-tools", [...disallowed].join(","));