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
@@ -184,6 +184,176 @@ function reconstructTasksFromEventLog(eventsPath: string, runId: string): TeamTa
184
184
  }
185
185
  }
186
186
 
187
+ /**
188
+ * N3 (security-audit run-1, 2026-10-29 — w9 loader-level hardening):
189
+ * tasks.json and manifest.json live under the shared-cwd state root, i.e. they
190
+ * are same-uid writable by WRITE-role workers (verify-gate.ts treats shared-cwd
191
+ * files as worker-mutable attacker input for exactly this reason). Before this
192
+ * fix the load seam cast whatever JSON.parse produced straight to
193
+ * TeamTaskState[]/TeamRunManifest, so poisoned fields flowed into resume /
194
+ * crash-recovery dispatch as execution authority (forged role →
195
+ * permissionForRole at pre-execution.ts:248; forged runKind selects the resume
196
+ * engine at team-tool.ts:673). The validators below are the bounded,
197
+ * backward-compatible answer: fail-closed on structurally poisoned entries
198
+ * (drop, never crash the load), permissive on well-formed legacy state.
199
+ * Provenance/integrity (HMAC) and role re-authorization vs team composition
200
+ * are explicitly deferred (need secret-management + dispatch-side context).
201
+ */
202
+
203
+ /** N3: loader DoG guard — a tasks array beyond this cap is truncated with an
204
+ * anomaly log. The maxTasksPerRun admission gate (state-store) caps dispatch
205
+ * cardinality; this bound merely stops an inflated file from dominating load
206
+ * time / memory before admission ever runs. 10_000 mirrors the default
207
+ * limits.maxTasksPerRun ceiling. */
208
+ export const MAX_LOADED_TASKS = 10_000;
209
+
210
+ const MAX_ANOMALY_EXAMPLES = 5;
211
+
212
+ /** N3: per-entry shape check — a plausible TeamTaskState is a non-null,
213
+ * non-array object whose id/runId/role/agent/title/status are strings, whose
214
+ * dependsOn is absent or an array of strings, and whose cwd is absent or a
215
+ * string. Deliberately shape-only: values (role names, cwd targets, status
216
+ * enums) are NOT re-authorized here — the loader has no dispatch-side context
217
+ * for what the approved composition was. */
218
+ function plausibleTaskShape(entry: unknown): entry is TeamTaskState {
219
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return false;
220
+ const task = entry as Record<string, unknown>;
221
+ if (typeof task.id !== "string") return false;
222
+ if (typeof task.runId !== "string") return false;
223
+ if (typeof task.role !== "string") return false;
224
+ if (typeof task.agent !== "string") return false;
225
+ if (typeof task.title !== "string") return false;
226
+ if (typeof task.status !== "string") return false;
227
+ if (task.dependsOn !== undefined) {
228
+ if (!Array.isArray(task.dependsOn) || task.dependsOn.some((dep) => typeof dep !== "string")) return false;
229
+ }
230
+ if (task.cwd !== undefined && typeof task.cwd !== "string") return false;
231
+ return true;
232
+ }
233
+
234
+ /**
235
+ * N3: validate a tasks array loaded from disk before it re-enters dispatch.
236
+ *
237
+ * - Entries failing the plausible-shape check are DROPPED with an anomaly log
238
+ * (fail-closed on poison) instead of crashing the load or leaking through.
239
+ * - Entries whose runId does not match the loading run's runId are DROPPED —
240
+ * mirrors the w3 DwfStore.load(expectedRunId) binding in
241
+ * dwf-state-store.ts: a foreign/stale/planted entry never hydrates as this
242
+ * run's execution authority.
243
+ * - Arrays longer than {@link MAX_LOADED_TASKS} are truncated (DoG guard).
244
+ *
245
+ * Clean input is returned UNCHANGED (same array reference, same entry objects)
246
+ * so legitimate legacy v0/v1 state loads byte-identical.
247
+ */
248
+ /** @internal — exported for tests pinning the N3 loader contract. */
249
+ export function sanitizeLoadedTasks(tasks: TeamTaskState[], runId: string): TeamTaskState[] {
250
+ const truncated = tasks.length > MAX_LOADED_TASKS;
251
+ const limit = truncated ? MAX_LOADED_TASKS : tasks.length;
252
+ const kept: TeamTaskState[] = [];
253
+ const shapeInvalid: string[] = [];
254
+ const foreignRunId: string[] = [];
255
+ for (let i = 0; i < limit; i++) {
256
+ const entry: unknown = tasks[i];
257
+ if (!plausibleTaskShape(entry)) {
258
+ const id = entry !== null && typeof entry === "object" && !Array.isArray(entry) ? (entry as { id?: unknown }).id : undefined;
259
+ shapeInvalid.push(typeof id === "string" ? id : `#${i}`);
260
+ continue;
261
+ }
262
+ // RunId binding (entry.runId is a string — the shape check above already
263
+ // failed entries without one, so legacy entries missing runId cannot
264
+ // masquerade as this run's tasks either).
265
+ if (entry.runId !== runId) {
266
+ foreignRunId.push(entry.id);
267
+ continue;
268
+ }
269
+ kept.push(entry);
270
+ }
271
+ if (truncated) {
272
+ logInternalError(
273
+ "state-store",
274
+ new Error(
275
+ `N3: tasks.json for run ${runId} holds ${tasks.length} entries — beyond the ${MAX_LOADED_TASKS} loader cap. Truncated to the first ${MAX_LOADED_TASKS} (dispatch admission caps are enforced separately).`,
276
+ ),
277
+ undefined,
278
+ "error",
279
+ );
280
+ }
281
+ if (shapeInvalid.length > 0) {
282
+ logInternalError(
283
+ "state-store",
284
+ new Error(
285
+ `N3: ${shapeInvalid.length} task entr${shapeInvalid.length === 1 ? "y" : "ies"} in tasks.json for run ${runId} failed shape validation and ${shapeInvalid.length === 1 ? "was" : "were"} DROPPED (worker-writable state is untrusted input; example id(s): ${shapeInvalid.slice(0, MAX_ANOMALY_EXAMPLES).join(",")}).`,
286
+ ),
287
+ undefined,
288
+ "error",
289
+ );
290
+ }
291
+ if (foreignRunId.length > 0) {
292
+ logInternalError(
293
+ "state-store",
294
+ new Error(
295
+ `N3: ${foreignRunId.length} task entr${foreignRunId.length === 1 ? "y" : "ies"} in tasks.json for run ${runId} carry a foreign runId and ${foreignRunId.length === 1 ? "was" : "were"} DROPPED (runId binding, mirrors DwfStore.load; example id(s): ${foreignRunId.slice(0, MAX_ANOMALY_EXAMPLES).join(",")}).`,
296
+ ),
297
+ undefined,
298
+ "error",
299
+ );
300
+ }
301
+ return truncated || shapeInvalid.length > 0 || foreignRunId.length > 0 ? kept : tasks;
302
+ }
303
+
304
+ /** N3: the run kinds a persisted manifest may legally select. Unknown values
305
+ * normalize to "team-run" (see sanitizeLoadedManifest) — matching the
306
+ * `manifest.runKind ?? "team-run"` consumer at team-tool.ts:673, so a poisoned
307
+ * runKind can only ever select the DEFAULT resume engine. */
308
+ const KNOWN_RUN_KINDS = new Set<string>(["team-run", "goal-loop", "dynamic-workflow"]);
309
+
310
+ /**
311
+ * N3: validate a manifest loaded from disk before it re-enters dispatch.
312
+ *
313
+ * - A payload that is not a JSON object at all (null/number/string/array) is
314
+ * NOT a manifest → undefined (fail-closed; previously the raw cast was
315
+ * returned and downstream field access was undefined behavior).
316
+ * - runKind is checked against {@link KNOWN_RUN_KINDS}: unknown values are
317
+ * normalized to "team-run" with a warn anomaly — deliberately NOT a throw,
318
+ * so poison degrades to the default engine instead of hiding the run.
319
+ * Absent runKind (legacy manifests) passes through unchanged: the consumer
320
+ * already defaults it.
321
+ * - Identity fields (runId/stateRoot/tasksPath/eventsPath/artifactsRoot) are
322
+ * intentionally NOT re-checked here — validateRunManifestPaths
323
+ * (state-store.ts) already owns that contract.
324
+ *
325
+ * Clean input is returned UNCHANGED (same object reference); normalization
326
+ * returns a shallow copy so parsed/cached objects are never mutated in place.
327
+ */
328
+ /** @internal — exported for tests pinning the N3 loader contract. */
329
+ export function sanitizeLoadedManifest(manifest: TeamRunManifest | undefined, runId: string): TeamRunManifest | undefined {
330
+ if (manifest === undefined) return undefined;
331
+ const raw: unknown = manifest;
332
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
333
+ logInternalError(
334
+ "state-store",
335
+ new Error(
336
+ `N3: manifest.json for run ${runId} parsed to a non-object — not a manifest, failing closed (run treated as corrupt/missing).`,
337
+ ),
338
+ undefined,
339
+ "error",
340
+ );
341
+ return undefined;
342
+ }
343
+ const kind: unknown = (raw as { runKind?: unknown }).runKind;
344
+ if (kind === undefined) return manifest;
345
+ if (typeof kind === "string" && KNOWN_RUN_KINDS.has(kind)) return manifest;
346
+ logInternalError(
347
+ "state-store",
348
+ new Error(
349
+ `N3: manifest runKind ${JSON.stringify(kind)} for run ${runId} is not a known run kind (team-run|goal-loop|dynamic-workflow) — normalizing to 'team-run' so only the default resume engine can be selected.`,
350
+ ),
351
+ undefined,
352
+ "warn",
353
+ );
354
+ return { ...manifest, runKind: "team-run" };
355
+ }
356
+
187
357
  /**
188
358
  * ST-4: Load tasks.json with corruption recovery (sync path).
189
359
  *
@@ -253,6 +423,11 @@ function migrateTasksFile(parsed: unknown, runId: string): TeamTaskState[] {
253
423
  const envelope = parsed as { schemaVersion?: unknown; tasks?: unknown };
254
424
  const detected = typeof envelope.schemaVersion === "number" ? envelope.schemaVersion : 0;
255
425
  if (detected !== CURRENT_TASKS_SCHEMA_VERSION) {
426
+ // N3 note: schemaVersion mismatch stays WARN-ONLY deliberately —
427
+ // forward compatibility is a design requirement (a newer-but-
428
+ // well-formed tasks.json must still load). Field-level poison is
429
+ // handled by sanitizeLoadedTasks (shape + runId binding + count cap)
430
+ // downstream, NOT by version rejection.
256
431
  logInternalError(
257
432
  "state-store",
258
433
  new Error(
@@ -279,17 +454,20 @@ export function loadTasksWithRecovery(tasksPath: string, eventsPath: string, run
279
454
  parsed = JSON.parse(content);
280
455
  } catch {
281
456
  // SyntaxError — corrupt file.
282
- const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content);
457
+ const reconstructed = sanitizeLoadedTasks(recoverCorruptTasks(tasksPath, eventsPath, runId, content), runId);
283
458
  if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
284
459
  return reconstructed;
285
460
  }
286
461
  if (!isRecognizableTasksPayload(parsed)) {
287
462
  // Neither v0 bare array nor v1+ envelope (e.g. `{}`) — corrupt.
288
- const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content);
463
+ const reconstructed = sanitizeLoadedTasks(recoverCorruptTasks(tasksPath, eventsPath, runId, content), runId);
289
464
  if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
290
465
  return reconstructed;
291
466
  }
292
- return migrateTasksFile(parsed, runId);
467
+ // N3: fail-closed validation of the migrated array before it re-enters
468
+ // dispatch (shape-check + runId binding + count cap) — see
469
+ // sanitizeLoadedTasks above.
470
+ return sanitizeLoadedTasks(migrateTasksFile(parsed, runId), runId);
293
471
  }
294
472
 
295
473
  /**
@@ -309,8 +487,9 @@ export function loadManifestWithRecovery(manifestPath: string, runId: string): T
309
487
  // ENOENT / ENOTDIR / other read error → legitimate missing run.
310
488
  return undefined;
311
489
  }
490
+ let parsed: unknown;
312
491
  try {
313
- return JSON.parse(content) as TeamRunManifest;
492
+ parsed = JSON.parse(content);
314
493
  } catch {
315
494
  // SyntaxError → corrupt manifest. Quarantine (preserve for diagnosis) + log,
316
495
  // then treat as missing. Do NOT reconstruct (infeasible from events).
@@ -325,6 +504,25 @@ export function loadManifestWithRecovery(manifestPath: string, runId: string): T
325
504
  );
326
505
  return undefined;
327
506
  }
507
+ // N3: a payload that parses but is not a JSON object (null/number/string/
508
+ // array) is not a manifest — fail closed the same way as unparseable
509
+ // (quarantine for diagnosis, run treated as missing).
510
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
511
+ quarantineCorruptFile(manifestPath);
512
+ logInternalError(
513
+ "state-store",
514
+ new Error(
515
+ `STATE-3/N3: manifest.json for run ${runId} is not a JSON object — quarantined to ${manifestPath}.corrupt-*. Run is now treated as missing. Preserve the .corrupt-* file for diagnosis.`,
516
+ ),
517
+ undefined,
518
+ "error",
519
+ );
520
+ return undefined;
521
+ }
522
+ // N3: runKind allowlist — unknown values normalize to "team-run" with a
523
+ // warn anomaly (never a throw), matching the `?? "team-run"` consumer at
524
+ // team-tool.ts:673.
525
+ return sanitizeLoadedManifest(parsed as TeamRunManifest, runId);
328
526
  }
329
527
 
330
528
  /**
@@ -342,14 +540,15 @@ export async function loadTasksWithRecoveryAsync(tasksPath: string, eventsPath:
342
540
  try {
343
541
  parsed = JSON.parse(content);
344
542
  } catch {
345
- const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content);
543
+ const reconstructed = sanitizeLoadedTasks(recoverCorruptTasks(tasksPath, eventsPath, runId, content), runId);
346
544
  if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
347
545
  return reconstructed;
348
546
  }
349
547
  if (!isRecognizableTasksPayload(parsed)) {
350
- const reconstructed = recoverCorruptTasks(tasksPath, eventsPath, runId, content);
548
+ const reconstructed = sanitizeLoadedTasks(recoverCorruptTasks(tasksPath, eventsPath, runId, content), runId);
351
549
  if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
352
550
  return reconstructed;
353
551
  }
354
- return migrateTasksFile(parsed, runId);
552
+ // N3: same fail-closed validation as the sync twin.
553
+ return sanitizeLoadedTasks(migrateTasksFile(parsed, runId), runId);
355
554
  }
@@ -26,7 +26,13 @@ import { appendEvent } from "../event-log/event-log.ts";
26
26
  import type { RunModelContext, TeamRunManifest, TeamTaskState } from "../types.ts";
27
27
  import { CURRENT_SCHEMA_VERSION } from "../types.ts";
28
28
  import { unregisterActiveRun } from "./active-run-registry.ts";
29
- import { extractTaskArray, loadManifestWithRecovery, loadTasksWithRecovery, loadTasksWithRecoveryAsync } from "./manifest-io.ts";
29
+ import {
30
+ extractTaskArray,
31
+ loadManifestWithRecovery,
32
+ loadTasksWithRecovery,
33
+ loadTasksWithRecoveryAsync,
34
+ sanitizeLoadedManifest,
35
+ } from "./manifest-io.ts";
30
36
  import { closeSqliteRunStateStore, getSqliteRunStateStore, isSqliteStateBackend, sqliteDbPath } from "./sqlite-run-state.ts";
31
37
 
32
38
  export { loadManifestWithRecovery, loadTasksWithRecovery } from "./manifest-io.ts";
@@ -436,15 +442,20 @@ export function createTasksFromWorkflow(
436
442
  }
437
443
 
438
444
  /**
439
- * Admission cap for {@link createRunManifest}: the effective
445
+ * Admission cap for dispatchable task sets: the effective
440
446
  * `limits.maxTasksPerRun` from loaded config, falling back to the hard
441
447
  * ceiling LIMIT_CEILINGS.maxTasksPerRun — no parsed config value can exceed
442
448
  * the ceiling (config-validation clamps it), so a manifest beyond the ceiling
443
449
  * is invalid regardless of config. Config-load failure degrades to the
444
450
  * ceiling only (an admission check must never itself block run creation on a
445
451
  * config read problem; loadConfig surfaces its own warnings separately).
452
+ *
453
+ * Exported (resume-skips-maxtasks-admission fix) so non-creation paths that
454
+ * reconstruct a dispatchable task set from PERSISTED state — team resume,
455
+ * retry-then-resume, special-kind adoption — can re-run the SAME invariant
456
+ * createRunManifest enforces, before re-queueing or dispatching.
446
457
  */
447
- function resolveMaxTasksPerRunAdmissionCap(cwd: string): number {
458
+ export function resolveMaxTasksPerRunAdmissionCap(cwd: string): number {
448
459
  try {
449
460
  const configured = loadConfig(cwd).config.limits?.maxTasksPerRun;
450
461
  return typeof configured === "number" && Number.isFinite(configured) ? configured : LIMIT_CEILINGS.maxTasksPerRun;
@@ -1204,18 +1215,22 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
1204
1215
  if (isSqliteStateBackend() && fs.existsSync(sqliteDbPath(stateRoot))) {
1205
1216
  const loaded = getSqliteRunStateStore(stateRoot).loadManifestAndTasks();
1206
1217
  if (loaded) {
1207
- if (loaded.manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1218
+ // N3: same manifest sanitization as the JSON path below (uniform
1219
+ // loader invariant — runKind leaving a loader is always allowlisted).
1220
+ const sanitizedManifest = sanitizeLoadedManifest(loaded.manifest, runId);
1221
+ if (!sanitizedManifest) return undefined;
1222
+ if (sanitizedManifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1208
1223
  logInternalError(
1209
1224
  "state-store",
1210
1225
  new Error(
1211
- `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${loaded.manifest.schemaVersion}. Run ${runId} may be incompatible.`,
1226
+ `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${sanitizedManifest.schemaVersion}. Run ${runId} may be incompatible.`,
1212
1227
  ),
1213
1228
  undefined,
1214
1229
  "warn",
1215
1230
  );
1216
1231
  }
1217
- if (!validateRunManifestPaths(cwd, runId, loaded.manifest, stateRoot, tasksPath)) return undefined;
1218
- return { manifest: loaded.manifest, tasks: loaded.tasks };
1232
+ if (!validateRunManifestPaths(cwd, runId, sanitizedManifest, stateRoot, tasksPath)) return undefined;
1233
+ return { manifest: sanitizedManifest, tasks: loaded.tasks };
1219
1234
  }
1220
1235
  // No manifest row yet: either a json-created legacy run (mirror exists,
1221
1236
  // JSON path below serves it) or a pre-birth crash (nothing anywhere —
@@ -1344,8 +1359,13 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
1344
1359
  // — the final stable state is what gets used. Because the retry loop
1345
1360
  // handles the crash case, we do NOT fail based on this comparison alone.
1346
1361
  // It does not indicate corruption on its own.
1347
- // S-01: warn (do not throw) on schemaVersion mismatch — future version
1348
- // bumps will add migration logic here.
1362
+ // N3: sanitize the disk-loaded manifest (non-object payload → fail closed;
1363
+ // runKind allowlist → unknown normalizes to "team-run" with a warn).
1364
+ manifest = sanitizeLoadedManifest(manifest, runId);
1365
+ // S-01 + N3 note: warn (do not throw) on schemaVersion mismatch — future
1366
+ // version bumps will add migration logic here. WARN-ONLY is deliberate
1367
+ // forward compat (a newer-but-well-formed manifest must still load;
1368
+ // field-level poison is handled by the N3 sanitizers, not version rejection).
1349
1369
  if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1350
1370
  logInternalError(
1351
1371
  "state-store",
@@ -1401,18 +1421,22 @@ export async function loadRunManifestByIdAsync(
1401
1421
  if (isSqliteStateBackend() && fs.existsSync(sqliteDbPath(stateRoot))) {
1402
1422
  const loaded = getSqliteRunStateStore(stateRoot).loadManifestAndTasks();
1403
1423
  if (loaded) {
1404
- if (loaded.manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1424
+ // N3: same manifest sanitization as the JSON path below (uniform
1425
+ // loader invariant — runKind leaving a loader is always allowlisted).
1426
+ const sanitizedManifest = sanitizeLoadedManifest(loaded.manifest, runId);
1427
+ if (!sanitizedManifest) return undefined;
1428
+ if (sanitizedManifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1405
1429
  logInternalError(
1406
1430
  "state-store",
1407
1431
  new Error(
1408
- `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${loaded.manifest.schemaVersion}. Run ${runId} may be incompatible.`,
1432
+ `Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${sanitizedManifest.schemaVersion}. Run ${runId} may be incompatible.`,
1409
1433
  ),
1410
1434
  undefined,
1411
1435
  "warn",
1412
1436
  );
1413
1437
  }
1414
- if (!validateRunManifestPaths(cwd, runId, loaded.manifest, stateRoot, tasksPath)) return undefined;
1415
- return { manifest: loaded.manifest, tasks: loaded.tasks };
1438
+ if (!validateRunManifestPaths(cwd, runId, sanitizedManifest, stateRoot, tasksPath)) return undefined;
1439
+ return { manifest: sanitizedManifest, tasks: loaded.tasks };
1416
1440
  }
1417
1441
  // No manifest row yet — fall through to the JSON path (legacy run or
1418
1442
  // pre-birth crash; see the sync twin above).
@@ -1533,8 +1557,13 @@ export async function loadRunManifestByIdAsync(
1533
1557
  // handles the crash case, we do NOT fail based on this comparison alone.
1534
1558
  // It does not indicate corruption on its own.
1535
1559
 
1536
- // S-01: warn (do not throw) on schemaVersion mismatch — future version
1537
- // bumps will add migration logic here.
1560
+ // N3: sanitize the disk-loaded manifest (non-object payload → fail closed;
1561
+ // runKind allowlist → unknown normalizes to "team-run" with a warn).
1562
+ manifest = sanitizeLoadedManifest(manifest, runId);
1563
+ // S-01 + N3 note: warn (do not throw) on schemaVersion mismatch — future
1564
+ // version bumps will add migration logic here. WARN-ONLY is deliberate
1565
+ // forward compat (a newer-but-well-formed manifest must still load;
1566
+ // field-level poison is handled by the N3 sanitizers, not version rejection).
1538
1567
  if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
1539
1568
  logInternalError(
1540
1569
  "state-store",
@@ -426,6 +426,15 @@ export interface TeamRunManifest {
426
426
  };
427
427
  /** Background dispatch discriminator. Default "team-run" runs executeTeamRun; "goal-loop" / "dynamic-workflow" dispatch to their respective runners. Absent = "team-run" for backward compatibility. */
428
428
  runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
429
+ /** w3-dwf-trust (2026-10-29): dispatch-time code pin for runKind="dynamic-workflow"
430
+ * runs. The dispatcher stamps the RESOLVED .dwf.ts script path plus the sha256 of
431
+ * its bytes AT DISPATCH; runDynamicWorkflow re-hashes the file before every load
432
+ * (fresh dispatch and both resume arms funnel through it) and refuses to execute
433
+ * on path/hash mismatch — closing the plant-between-dispatch-and-resume swap for a
434
+ * script in a worker-writable discovery dir. Purely additive/optional: absent on
435
+ * every manifest written before this field existed (and on dispatchers that do not
436
+ * stamp it yet) → no pin verification. */
437
+ dynamicScriptPin?: { scriptPath: string; sha256: string };
429
438
  /** round-14 P1-5: typed workflow arguments accessible in .dwf.ts scripts via ctx.args<T>(). Any JSON value; default {} when unset. */
430
439
  args?: unknown;
431
440
  /** Per-run token budget snapshot. budgetTotal is the cap; budgetWarning/budgetAbort are fractions in [0,1] at which the team-runner emits run.budget_warning / run.budget_abort. budgetUnlimited=true opts out of enforcement. Optional for backward compat. */
@@ -27,6 +27,7 @@ import * as path from "node:path";
27
27
 
28
28
  import { readCrewAgentEventsCursor } from "../../runtime/crew-agent-records.ts";
29
29
  import type { TeamRunManifest } from "../../state/types.ts";
30
+ import { stripTerminalSequences } from "../../utils/safe-terminal-text.ts";
30
31
 
31
32
  const MAX_TRANSCRIPT_ITEMS = 500;
32
33
 
@@ -127,21 +128,148 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
127
128
  * (role:"toolResult" message_end).
128
129
  */
129
130
  function normalizeResultContent(raw: unknown): Array<{ type: string; text?: string }> {
130
- if (typeof raw === "string") return [{ type: "text", text: raw }];
131
+ // w7a-render: every string on this path is worker-written and handed to pi
132
+ // host components (ToolExecutionComponent) — strip terminal escape
133
+ // sequences (OSC 52/OSC 8/CSI/APC + residual introducers) at the boundary.
134
+ if (typeof raw === "string") return [{ type: "text", text: stripTerminalSequences(raw) }];
131
135
  if (!Array.isArray(raw)) return [];
132
136
  return raw.flatMap((part) => {
133
- if (typeof part === "string") return [{ type: "text", text: part }];
137
+ if (typeof part === "string") return [{ type: "text", text: stripTerminalSequences(part) }];
134
138
  const record = asRecord(part);
135
139
  if (!record) return [];
136
140
  return [
137
141
  {
138
142
  type: typeof record.type === "string" ? record.type : "text",
139
- text: typeof record.text === "string" ? record.text : undefined,
143
+ text: typeof record.text === "string" ? stripTerminalSequences(record.text) : undefined,
140
144
  },
141
145
  ];
142
146
  });
143
147
  }
144
148
 
149
+ /**
150
+ * w7a-render: sanitize the printable string fields of a retained assistant
151
+ * message (text + thinking content parts) before the full message object is
152
+ * handed to pi's AssistantMessageComponent, which renders them verbatim.
153
+ * Returns the SAME object when nothing needed stripping (component-cache
154
+ * fingerprint stays stable). Identity fields (usage/role/stopReason) and
155
+ * non-string payloads pass through untouched.
156
+ */
157
+ function sanitizeMessageForRender(message: Record<string, unknown>): Record<string, unknown> {
158
+ const content = message.content;
159
+ if (!Array.isArray(content)) return message;
160
+ let touched = false;
161
+ const parts = content.map((part) => {
162
+ const record = asRecord(part);
163
+ if (!record) return part;
164
+ if (record.type === "text" && typeof record.text === "string") {
165
+ const clean = stripTerminalSequences(record.text);
166
+ if (clean !== record.text) {
167
+ touched = true;
168
+ return { ...record, text: clean };
169
+ }
170
+ return part;
171
+ }
172
+ if (record.type === "thinking") {
173
+ let partTouched = false;
174
+ const clean: Record<string, unknown> = { ...record };
175
+ if (typeof record.thinking === "string") {
176
+ clean.thinking = stripTerminalSequences(record.thinking);
177
+ partTouched = clean.thinking !== record.thinking;
178
+ }
179
+ if (typeof record.text === "string") {
180
+ clean.text = stripTerminalSequences(record.text);
181
+ partTouched ||= clean.text !== record.text;
182
+ }
183
+ if (partTouched) {
184
+ touched = true;
185
+ return clean;
186
+ }
187
+ return part;
188
+ }
189
+ return part;
190
+ });
191
+ return touched ? { ...message, content: parts } : message;
192
+ }
193
+
194
+ /**
195
+ * W9-R1 (w7a-render deferred residual): `tool_execution_start` event.args are
196
+ * worker-controlled and reach pi's ToolExecutionComponent verbatim — it
197
+ * interpolates top-level keys and string values RAW (formatToolCallWithArgs)
198
+ * and hands the whole record to per-tool call renderers. Strip terminal
199
+ * sequences from every string leaf (and every object key), recursing into
200
+ * nested objects/arrays down to ARGS_SANITIZE_MAX_DEPTH. Non-string leaves
201
+ * (numbers/booleans/null) are inert — they only ever render through
202
+ * JSON.stringify, whose output escapes control characters — and values below
203
+ * the depth cap likewise only surface JSON-escaped, so both are passed
204
+ * through untouched (nothing is sanitized that is not rendered raw).
205
+ * Returns the SAME object when nothing needed stripping (component-cache
206
+ * fingerprint stays stable, matching sanitizeMessageForRender).
207
+ */
208
+ const ARGS_SANITIZE_MAX_DEPTH = 6;
209
+
210
+ function sanitizeArgsForRender(
211
+ args: Record<string, unknown>,
212
+ depth = ARGS_SANITIZE_MAX_DEPTH,
213
+ ): { value: Record<string, unknown>; touched: boolean } {
214
+ // Null prototype: event args are unvalidated worker JSON — a "__proto__"
215
+ // key must never reach a prototype setter on the rebuilt record (same
216
+ // reasoning as config-validation's asRecord copy).
217
+ const clean: Record<string, unknown> = Object.create(null);
218
+ let touched = false;
219
+ for (const [rawKey, rawValue] of Object.entries(args)) {
220
+ const key = stripTerminalSequences(rawKey);
221
+ if (key !== rawKey) touched = true;
222
+ if (typeof rawValue === "string") {
223
+ const value = stripTerminalSequences(rawValue);
224
+ if (value !== rawValue) touched = true;
225
+ clean[key] = value;
226
+ continue;
227
+ }
228
+ const record = asRecord(rawValue);
229
+ if (depth > 0 && record) {
230
+ const nested = sanitizeArgsForRender(record, depth - 1);
231
+ if (nested.touched) touched = true;
232
+ clean[key] = nested.value;
233
+ continue;
234
+ }
235
+ if (depth > 0 && Array.isArray(rawValue)) {
236
+ const nested = sanitizeArgsArrayForRender(rawValue, depth - 1);
237
+ if (nested.touched) touched = true;
238
+ clean[key] = nested.value;
239
+ continue;
240
+ }
241
+ clean[key] = rawValue;
242
+ }
243
+ return touched ? { value: clean, touched: true } : { value: args, touched: false };
244
+ }
245
+
246
+ function sanitizeArgsArrayForRender(items: unknown[], depth: number): { value: unknown[]; touched: boolean } {
247
+ let touched = false;
248
+ const clean = items.map((entry) => {
249
+ if (typeof entry === "string") {
250
+ const value = stripTerminalSequences(entry);
251
+ if (value !== entry) {
252
+ touched = true;
253
+ return value;
254
+ }
255
+ return entry;
256
+ }
257
+ const record = asRecord(entry);
258
+ if (depth > 0 && record) {
259
+ const nested = sanitizeArgsForRender(record, depth - 1);
260
+ if (nested.touched) touched = true;
261
+ return nested.value;
262
+ }
263
+ if (depth > 0 && Array.isArray(entry)) {
264
+ const nested = sanitizeArgsArrayForRender(entry, depth - 1);
265
+ if (nested.touched) touched = true;
266
+ return nested.value;
267
+ }
268
+ return entry;
269
+ });
270
+ return touched ? { value: clean, touched: true } : { value: items, touched: false };
271
+ }
272
+
145
273
  function textFromContent(content: unknown): string {
146
274
  if (!Array.isArray(content)) return "";
147
275
  return content
@@ -178,8 +306,12 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
178
306
  const items: CrewTranscriptItem[] = [];
179
307
 
180
308
  if (type === "tool_execution_start") {
181
- const name = typeof event.toolName === "string" ? event.toolName : "tool";
182
- const args = (event.args as Record<string, unknown>) ?? {};
309
+ // w7a-render: toolName is interpolated into ToolExecutionComponent's title
310
+ const name = stripTerminalSequences(typeof event.toolName === "string" ? event.toolName : "tool");
311
+ // w9-r1 (w7a deferred): args reach ToolExecutionComponent verbatim —
312
+ // keys and string values are interpolated raw, nested structures go to
313
+ // call renderers. Strip terminal sequences at the parser boundary.
314
+ const args = sanitizeArgsForRender((event.args as Record<string, unknown>) ?? {}).value;
183
315
  const id = `${name}#${seq}`;
184
316
  const item: PendingTool = { type: "tool", name, toolCallId: id, args, seq };
185
317
  pending.set(id, item);
@@ -223,7 +355,7 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
223
355
 
224
356
  if (message.role === "assistant") {
225
357
  const content = Array.isArray(message.content) ? message.content : [];
226
- const text = textFromContent(content);
358
+ const text = stripTerminalSequences(textFromContent(content));
227
359
  if (text) {
228
360
  // Compaction can carry usage at the RECORD level (usage-only
229
361
  // tail) instead of inside the message — merge it in so the
@@ -234,7 +366,13 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
234
366
  if (recordUsage) {
235
367
  merged = { ...message, usage: messageUsage ? { ...messageUsage, ...recordUsage } : recordUsage };
236
368
  }
237
- items.push({ type: "assistant", text, seq, message: merged, usage: normalizeUsage(merged.usage) });
369
+ items.push({
370
+ type: "assistant",
371
+ text,
372
+ seq,
373
+ message: sanitizeMessageForRender(merged),
374
+ usage: normalizeUsage(merged.usage),
375
+ });
238
376
  }
239
377
  // toolResult parts carry name + content; fold them into pending starts
240
378
  // (normalized to the same updateResult envelope as the pi ≥0.84
@@ -242,7 +380,9 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
242
380
  for (const part of content) {
243
381
  const item = asRecord(part);
244
382
  if (item?.type !== "toolResult") continue;
245
- const name = typeof item.name === "string" ? item.name : "tool";
383
+ // Sanitize the fold key EXACTLY like tool_execution_start names so a
384
+ // payload-carrying toolName still pairs with its result.
385
+ const name = stripTerminalSequences(typeof item.name === "string" ? item.name : "tool");
246
386
  const isError = item.isError === true;
247
387
  matchPending(
248
388
  pending,
@@ -258,7 +398,7 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
258
398
  }
259
399
 
260
400
  if (message.role === "user") {
261
- const text = textFromContent(message.content);
401
+ const text = stripTerminalSequences(textFromContent(message.content));
262
402
  if (text) items.push({ type: "user", text, seq });
263
403
  return items;
264
404
  }
@@ -266,7 +406,7 @@ function parseEventRecord(record: Record<string, unknown>, pending: Map<string,
266
406
 
267
407
  // System / other events become a dim system line.
268
408
  if (type && type !== "message_update") {
269
- const text = typeof event.text === "string" ? event.text : "";
409
+ const text = stripTerminalSequences(typeof event.text === "string" ? event.text : "");
270
410
  if (text) items.push({ type: "system", text, seq });
271
411
  }
272
412
 
@@ -314,7 +454,7 @@ export function readAgentTranscript(manifest: TeamRunManifest, taskId: string):
314
454
  const prompt = readWorkerPrompt(manifest, taskId);
315
455
  if (prompt !== undefined) {
316
456
  promptSeeded.add(key);
317
- buffer.unshift({ type: "user", text: prompt, seq: 0 });
457
+ buffer.unshift({ type: "user", text: stripTerminalSequences(prompt), seq: 0 });
318
458
  }
319
459
  }
320
460
  if (buffer.length > MAX_TRANSCRIPT_ITEMS) {
@@ -6,7 +6,7 @@
6
6
 
7
7
  import * as piTui from "@earendil-works/pi-tui";
8
8
  import { PiTeamsRuntimeConfigSchema, PiTeamsUiConfigSchema } from "../schema/config-schema.ts";
9
- import { truncateToWidth, visibleWidth } from "../utils/visual.ts";
9
+ import { sanitizeUntrustedLine, truncateToWidth, visibleWidth } from "../utils/visual.ts";
10
10
  import { keyOf, matchesKey } from "./key-utils.ts";
11
11
  import { CURSOR, canopyLine, formatHint, overflowHint, RAIL, railLine } from "./rail.ts";
12
12
 
@@ -308,9 +308,14 @@ const SETTINGS: SettingDef[] = [
308
308
  label: "Pi UI Theme",
309
309
  type: "action",
310
310
  action: "piTheme",
311
- values: discoverPiThemes().map((t) => t.name),
311
+ // w7a-render: theme names (and the active-theme value read from
312
+ // ~/.pi/agent/settings.json) derive from worker-writable files — sanitize
313
+ // before they reach SelectSubmenu.render, which interpolates items verbatim.
314
+ values: discoverPiThemes().map((t) => sanitizeUntrustedLine(t.name)),
312
315
  tab: "themes",
313
- description: "Overall terminal theme. Switches live (no restart). Currently: " + (getActivePiTheme() ?? "dark (default)"),
316
+ description:
317
+ "Overall terminal theme. Switches live (no restart). Currently: " +
318
+ sanitizeUntrustedLine(getActivePiTheme() ?? "dark (default)"),
314
319
  },
315
320
  // Autonomous
316
321
  {