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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.49",
3
+ "version": "0.11.51",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -202,6 +202,8 @@ End-to-end verification discipline for pi-crew changes. Distilled from the broke
202
202
 
203
203
  > **2026-10-28-b update (ROOT-CAUSE CLOSED + fd3 transport + review-wave fixes, commits `38d5ed58`..`5190e66b`, cold-verified review + 3-agent verification)**: (1) **ROOT-CAUSE incident 0.11.46/47 (đóng)** — pi's `readPipedStdin` (pi@1.0.4 `dist/main.js:52-65`) đọc TOÀN BỘ piped stdin làm PROMPT CONTENT trong mọi mode trừ `--mode=rpc` ⇒ handshake fd0 race 3 cách: token JSON thành literal prompt text (SECRET LEAK — chứng minh trên transcript thật), stream-ended creds-less câm lặng, reader `pause()` trước EOF drain → pi boot await deadlock → **TỰ exit 0** (KHÔNG phải parent SIGTERM — strace cũ misread) + takeOverStdout buffer ⇒ ~0 bytes. **Fix: handshake dời sang fd3** (stdio entry 4 `['ignore','pipe','pipe','pipe']`, `readFirstLineFromFd(BROKER_HANDSHAKE_FD=3)`; fd0 về `"ignore"` — shape pre-0.11.46); detached runner GIỮ fd0 (node process, không race). Gate `PI_CREW_BROKER_STDIN` vẫn OPT-IN. Evidence: pre-fix pi@1.0.4 gate-ON 5/5 chết (~1s, 0B) → post-fix 2/2 sống (106-110KB, ~3.2s, 0 leak); integration live-worker giờ assert CẢ gate-ON liveness (bỏ comment "DO NOT assert liveness") + leak-check `v":2` không xuất hiện trong output. **Contract nền: fd0 KHÔNG BAO GIỜ là kênh sideband của extension khi spawn pi binary — dùng stdio entry 4+ (fd3).** (2) **T7 judge mechanics ĐỔI** — dispatch goal giờ mang nonce `[t7-nonce:<uuid>]`, judge CHỈ dirs có nonce trong events.jsonl (hết race newest-mtime với run đồng thời); non-terminal run = FAIL (check TRƯỚC killed-branch); stdout/stderr của dispatch được capture (cap 64KB) vào failure reason + audit. (3) **T7 triggers mở rộng** — thêm `src/(state|utils|agents|teams|workflows|hooks|observability)/` + `scripts/` (gate changes live-test the gate). (4) diff-base tag filter `^v\d+\.\d+\.\d+$` + porcelain rename both-sides; `PI_CREW_BATTERY_SKIP` giờ sống qua prepublishOnly path. (5) maxTasksPerRun admission cap tại createRunManifest (pre-dispatch); seam fix gate đọc `options.env ?? process.env` (test inject được — hết mutate process.env); `buildBrokerStdinLine` warn >60KiB (reader abort ~131KiB thực tế Linux — pipe chunks). (6) **CI 'Checks' step có gates NGOÀI battery list** (`check:env-vars`, `check:decision-drift`) — thêm vào leader local gate list sau mọi src edit.
204
204
 
205
+ > **2026-10-28-c update (LOW-1 DEFAULT-ON flip — commit riêng ngay sau `deb09c5e`/v0.11.49)**: `PI_CREW_BROKER_STDIN` gate lật **DEFAULT-ON** — cả 2 gate (`resolveBrokerStdinHandshakeEnabled` child-pi-spawn.ts + `resolveBrokerStdinFallbackEnabled` crew-broker-child.ts) đổi `=== "1"` → `!== "0"` (chỉ đúng `0` opt-out; unset/`1`/bất kỳ giá trị khác = ON — kể cả garbage như `true`). Fix-1 spawner vẫn ghi `"1"` vào built.env khi gate ON (pin route + tương thích worker pre-flip); parent opt-out ⇒ token ở env child (worker reader tự disarm). T7 live-worker test có thêm pin DEFAULT (gate unset + fd3 payload → alive + no-leak). **Meta-rule của update 2026-10-28 (1) vẫn nguyên: flip này là ĐÚNG lớp incident 0.11.46/47 — battery T7 REAL-TEST BẮT BUỘC trước publish, không chỉ CI.** Skew case mới: gate unset + KHÔNG fd3 pipe (old-parent shape) → `readFirstLineFromFd(3)` missing-fd contract = undefined (bounded, không treo) — pin bởi test đầu của `broker-stdin-reader-liveworker.test.ts`.
206
+
205
207
  ## Core principle: disk ≠ live Pi
206
208
 
207
209
  Two locations hold pi-crew state:
@@ -1,6 +1,7 @@
1
1
  import { getCrewEnv } from "../config/env-vars.ts";
2
2
  import type { RoleToolConfig } from "../config/role-tools.ts";
3
3
  import { getToolConfig, isScratchpadEnabledForRole } from "../config/role-tools.ts";
4
+ import { permissionForRole } from "../runtime/role-permission.ts";
4
5
 
5
6
  /**
6
7
  * F1 (v0.7.9): canonical built-in tool name list. Used by `parseToolsField`
@@ -146,6 +147,13 @@ export function getAgentSessionOptions(role: string): {
146
147
  *
147
148
  * Returns `{ tools, excludeTools }` where each is `undefined` when no
148
149
  * restriction of that kind applies (so callers no-op cleanly).
150
+ *
151
+ * S-7 (w4b): INDEPENDENT of both rules above, a role classified read_only by
152
+ * `permissionForRole` (including the default-deny for unknown names) never
153
+ * resolves bash/edit/write — they are stripped from the allowlist and merged
154
+ * into the excludeTools denylist regardless of source or frontmatter. The
155
+ * resolved policy is therefore safe to emit verbatim on BOTH spawn paths
156
+ * (child-pi `pi-args.ts` and live-session `filterActiveTools`).
149
157
  */
150
158
  export interface ResolvedToolPolicy {
151
159
  /** Allowlist; undefined = no allowlist restriction (all built-ins allowed). */
@@ -160,9 +168,15 @@ function uniqueToolMerge(...lists: Array<string[] | undefined>): string[] | unde
160
168
  }
161
169
 
162
170
  export function resolveToolPolicy(agent: AgentConfig, role?: string): ResolvedToolPolicy {
171
+ // Normalize raw frontmatter shapes BEFORE any array operation: inline /
172
+ // runtime-constructed agents may carry the raw CSV string (the type says
173
+ // string[], but callers historically pass the raw form — pi-args.ts docs).
174
+ // Without this, the array filters below crash on a string, and consumers
175
+ // building a Set from the "list" would spread a string into single chars.
176
+ const declaredTools = normalizeToolList(agent.tools);
163
177
  const roleConfig = role ? getToolConfig(role) : {};
164
178
  // allowlist: source-aware precedence (see doc above).
165
- const explicitTools = agent.source === "builtin" ? (roleConfig.tools ?? agent.tools) : (agent.tools ?? roleConfig.tools);
179
+ const explicitTools = agent.source === "builtin" ? (roleConfig.tools ?? declaredTools) : (declaredTools ?? roleConfig.tools);
166
180
  let tools =
167
181
  agent.loadMode === "lean" && agent.defaultTools?.length ? uniqueToolMerge(explicitTools, agent.defaultTools) : explicitTools;
168
182
  // denylist: additive merge of role excludeTools + agent disallowedTools.
@@ -179,9 +193,52 @@ export function resolveToolPolicy(agent: AgentConfig, role?: string): ResolvedTo
179
193
  tools = tools ? tools.filter((t) => t !== "bash") : tools;
180
194
  excludeTools = uniqueToolMerge(excludeTools, ["bash"]);
181
195
  }
196
+ // S-7 (w4b security fix — mirrors S-6 onto the tools path): a role
197
+ // classified read_only by permissionForRole must never receive
198
+ // execution-capable tools from ANY frontmatter declaration, wildcard
199
+ // expansion, or default fallback, regardless of agent.source — user intent
200
+ // does not outrank the tier. Project/project-pi frontmatter previously
201
+ // REPLACED the role allowlist wholesale (and custom role names had no
202
+ // role allowlist at all — getToolConfig {}), so a repo-authored
203
+ // `tools: bash` armed host-level command execution on a worker whose
204
+ // permission tier is read_only (default-deny). The denylist side is
205
+ // additive: it can never be weakened by frontmatter omission.
206
+ // F10: normalize underscore→hyphen before the tier lookup (same convention
207
+ // as isScratchpadEnabledForRole) — a raw `test_engineer` role must classify
208
+ // as its hyphenated WRITE counterpart, not default-deny to read_only.
209
+ if (role && permissionForRole(normalizeRoleKey(role)) === "read_only") {
210
+ tools = tools ? tools.filter((t) => !EXECUTION_TOOLS.has(t)) : tools;
211
+ excludeTools = uniqueToolMerge(excludeTools, [...EXECUTION_TOOLS]);
212
+ }
182
213
  return { tools, excludeTools };
183
214
  }
184
215
 
216
+ /**
217
+ * S-7: execution-capable builtin tools. A read_only-tier role never holds
218
+ * these, no matter what any frontmatter or role-config allowlist declares.
219
+ * (`scratchpad`/`sh` execution is already gated by S-6 in
220
+ * isScratchpadEnabledForRole — the two guards together close both execution
221
+ * paths for read-only roles.)
222
+ */
223
+ const EXECUTION_TOOLS: ReadonlySet<string> = new Set(["bash", "edit", "write"]);
224
+
225
+ /** F10: hyphen/underscore role-key normalization (mirrors getToolConfig). */
226
+ function normalizeRoleKey(role: string): string {
227
+ return role.includes("_") ? role.replaceAll("_", "-") : role;
228
+ }
229
+
230
+ /**
231
+ * Normalize a declared tool list into a clean string[]. Accepts the raw CSV
232
+ * string form (parseToolsField semantics: `*`/`all` expand to the builtin
233
+ * list, `none` → []) and plain arrays (trim + drop empties, NO wildcard
234
+ * re-expansion — an already-parsed array passes through verbatim).
235
+ */
236
+ function normalizeToolList(raw: unknown): string[] | undefined {
237
+ if (raw === undefined || raw === null) return undefined;
238
+ if (Array.isArray(raw)) return raw.map((t) => String(t).trim()).filter(Boolean);
239
+ return parseToolsField(raw);
240
+ }
241
+
185
242
  /**
186
243
  * P2: should `bash` be demoted (removed) for a scratchpad-armed role?
187
244
  *
@@ -828,6 +828,24 @@ function parseOtlpConfig(value: unknown): CrewOtlpConfig | undefined {
828
828
  headers: Object.keys(headers).length > 0 ? headers : undefined,
829
829
  intervalMs: parseWithSchema(Type.Integer({ minimum: 5000 }), obj.intervalMs),
830
830
  };
831
+ // W9-R1 (w7b deferred): refuse credential-bearing endpoints at CONFIG
832
+ // VALIDATION time, mirroring the runtime refusal in otlp-exporter.ts
833
+ // validateEndpoint. The error text renders the ORIGIN only — the
834
+ // credential-bearing URL is never echoed (unparseable URLs refuse without
835
+ // echoing any part of the endpoint).
836
+ if (otlp.endpoint !== undefined && /^https?:\/\/[^/?#]*@/.test(otlp.endpoint)) {
837
+ let origin = "";
838
+ try {
839
+ origin = new URL(otlp.endpoint).origin;
840
+ } catch {
841
+ origin = "";
842
+ }
843
+ throw new Error(
844
+ origin
845
+ ? `OTLP endpoint must not embed credentials (userinfo) — pass them via headers instead: ${origin}`
846
+ : "OTLP endpoint must not embed credentials (userinfo) — pass them via headers instead",
847
+ );
848
+ }
831
849
  return Object.values(otlp).some((entry) => entry !== undefined) ? otlp : undefined;
832
850
  }
833
851
 
@@ -191,6 +191,10 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
191
191
  PI_CREW_DELEGATE_ENABLED: { kind: "child-write", reason: "child-pi-spawn dormant delegate gate" },
192
192
  PI_CREW_STATE_ROOT: { kind: "child-write", reason: "child-pi-spawn mailbox root" },
193
193
  PI_CREW_BROKER_RUN_ID: { kind: "child-write", reason: "child-pi-spawn broker handshake" },
194
+ PI_CREW_MAILBOX_VERIFY_KEY: {
195
+ kind: "child-write",
196
+ reason: "SEC-2026-11 w1a: PUBLIC base64 SPKI Ed25519 verify key handed to children via built.env (child-pi-spawn) for coordination-row verify-only; public material — possession cannot forge rows (private key is heap-only in the root session)",
197
+ },
194
198
  PI_CREW_AGENT_EVENTS_PATH: { kind: "child-write", reason: "prepareSurfaceSpawn recorder log" },
195
199
  PI_CREW_BROKER_SOCKET: { kind: "child-write", reason: "child-pi-spawn broker handshake (never ambient — per-run socket)" },
196
200
  PI_CREW_BROKER_TOKEN: {
@@ -200,7 +204,7 @@ const CREW_ENV_FORWARDING_DECL: Record<string, CrewEnvForwardDecision> = {
200
204
  PI_CREW_BROKER_TASK_ID: { kind: "child-write", reason: "child-pi-spawn broker handshake" },
201
205
  PI_CREW_BROKER_STDIN: {
202
206
  kind: "child-write",
203
- reason: "LOW-1 broker-credential transport gate — read by the HOST spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route, then RE-WRITTEN by that spawner into every brokered child's built.env ('1') so the worker-side mirror gate (crew-broker-child .resolveBrokerStdinFallbackEnabled, which reads its OWN process.env) actually sees it — ambient forwarding never reaches the child, so child-write is the only channel that wires the route through (FIX-1 2026-10-28). Never ambient-allowlisted: route selection is per-spawn",
207
+ reason: "LOW-1 broker-credential transport gate (DEFAULT-ON since 2026-10-28 — only exact '0' opts out) — read by the HOST spawner (child-pi-spawn .resolveBrokerStdinHandshakeEnabled) to pick env vs stdin route, then RE-WRITTEN by that spawner into every gate-ON brokered child's built.env ('1') so the worker-side mirror gate (crew-broker-child .resolveBrokerStdinFallbackEnabled, which reads its OWN process.env) is armed deterministically and pre-flip workers (mirror === '1') stay compatible — ambient forwarding never reaches the child, so child-write is the only channel that wires the route through (FIX-1 2026-10-28). Never ambient-allowlisted: route selection is per-spawn",
204
208
  },
205
209
  PI_CREW_INFERENCE_BROKER: {
206
210
  kind: "gap",
@@ -636,6 +636,10 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
636
636
  parser: "number",
637
637
  doc: "swap-detection mtime pin for restore snapshots (scratchpad-lifecycle.ts:302)",
638
638
  },
639
+ PI_CREW_MAILBOX_VERIFY_KEY: {
640
+ name: "PI_CREW_MAILBOX_VERIFY_KEY",
641
+ doc: "SEC-2026-11 w1a coordination sender-binding: base64 SPKI Ed25519 public verify key handed to child workers at spawn (child-pi-spawn.ts) so they can VERIFY parent-signed mailbox/steering rows; the PRIVATE signing key never leaves the root session heap (mailbox.ts signingKeypair). Children verify-only — possession of the public key cannot forge rows",
642
+ },
639
643
  PI_CREW_MAX_AUTO_FALLBACKS: {
640
644
  name: "PI_CREW_MAX_AUTO_FALLBACKS",
641
645
  parser: "int",
@@ -668,7 +672,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
668
672
  },
669
673
  PI_CREW_BROKER_STDIN: {
670
674
  name: "PI_CREW_BROKER_STDIN",
671
- doc: "LOW-1 closure (2026-10-27) broker-credential transport gate, OPT-IN since v0.11.48 (2026-10-28): '1' sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over a DEDICATED fd3 spawn pipe (one buildBrokerStdinLine v2 payload on stdio entry 4; heap → fd3 → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader; fd0 stays 'ignore') so the token never appears in the child env; unset/any-other-value (default) keeps the LEGACY ENV ROUTE (token in built.env) on BOTH sides — the original fd0 variant was caught by the live battery (real-test-pi-crew T7) killing real headless workers with a silent exit 0 (root cause: pi@1.0.4 readPipedStdin consumes all piped fd0 as prompt content); the fd3 transport removes that race. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side fd3 read skips --mode rpc argv by design)",
675
+ doc: "LOW-1 closure (2026-10-27) broker-credential transport gate, DEFAULT-ON since 2026-10-28 (was OPT-IN v0.11.48–0.11.49): unset (default)/'1'/any value other than exactly '0' sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over a DEDICATED fd3 spawn pipe (one buildBrokerStdinLine v2 payload on stdio entry 4; heap → fd3 → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader; fd0 stays 'ignore') so the token never appears in the child env; exactly '0' opts OUT (rollback to the LEGACY ENV ROUTE — token in built.env — on BOTH sides) — the original fd0 variant was caught by the live battery (real-test-pi-crew T7) killing real headless workers with a silent exit 0 (root cause: pi@1.0.4 readPipedStdin consumes all piped fd0 as prompt content); the fd3 transport removes that race. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side fd3 read skips --mode rpc argv by design)",
672
676
  },
673
677
  PI_CREW_GUEST: {
674
678
  name: "PI_CREW_GUEST",
@@ -1,4 +1,5 @@
1
1
  import { collectSensitiveConfigPaths } from "../schema/sensitive-config-paths.ts";
2
+ import { getCrewEnv } from "./env-vars.ts";
2
3
  import type { ConfigValidationResult, PiTeamsConfig } from "./types.ts";
3
4
 
4
5
  /**
@@ -43,6 +44,14 @@ export function projectOverrideWarning(projectPath: string, dottedPath: string):
43
44
  * `limits.maxConcurrentWorkers` deliberately has NO entry: the schema already
44
45
  * bounds it (Type.Integer minimum 1, reader-side sanity ceilings) and a project
45
46
  * can only lower it — i.e. tighten — so no drop is needed (ADR Wave 1A).
47
+ *
48
+ * `runtime.embeddedWorkers` deliberately has NO entry either (w4b): the whole
49
+ * block is sensitive-marked in the schema (user-only), so the unconditional
50
+ * walk above already drops it from the project tier — there is no residual
51
+ * project-tier surface to guard-tier. Do not re-add conditional entries for
52
+ * it; if the block ever loses its sensitive mark, routing to the in-process
53
+ * embedded tier becomes project-settable again, which is the bug class this
54
+ * table exists to prevent.
46
55
  */
47
56
  export const CONDITIONAL_PROJECT_DROPS: Record<string, (value: unknown) => boolean> = {
48
57
  // Legacy exception (ADR Decision 3, folded in here): only `=== false`
@@ -170,3 +179,49 @@ export function sanitizeProjectConfig(projectPath: string, userConfig: PiTeamsCo
170
179
 
171
180
  /** @internal — direct-test seam for Phase 2.2 extraction target (refactor-plan step 1.9c). */
172
181
  export const __test__sanitizeProjectConfig = sanitizeProjectConfig;
182
+
183
+ // ─── w4b defense-in-depth: the embedded tier must not void the sandbox opt-in ───
184
+ // A project/user config can route a role onto the U11 EMBEDDED tier
185
+ // (runtime.embeddedWorkers), where the worker's Pi session runs IN-PROCESS in
186
+ // the host pi process. Both deterministic host protections live ONLY on the
187
+ // child spawn path: the PI_CREW_SANDBOX=bwrap OS boundary (child-pi.ts
188
+ // resolveSandboxConfig → sandbox.ts) and the env allowlist secret scrub
189
+ // (child-pi-spawn.ts sanitizeEnvSecrets). An embedded-routed task never
190
+ // reaches either — so while the sandbox posture is ON, routing to the
191
+ // in-process tier silently voids the user's explicit opt-in. The schema now
192
+ // removes the PROJECT tier's authority to enable embeddedWorkers; this gate is
193
+ // the second layer: even a USER-tier enable must refuse to route while the
194
+ // sandbox is on (fail closed, like resolveSandboxConfig itself).
195
+
196
+ /**
197
+ * FAIL-CLOSED: must the embedded tier refuse routing because a sandbox
198
+ * posture is configured?
199
+ *
200
+ * Mirrors resolveSandboxConfig's value handling (sandbox.ts): `""` and `"0"
201
+ * mean OFF; ANY other non-empty value means sandbox intent — `"bwrap"` is the
202
+ * active boundary, and unrecognized values throw on the child path entirely
203
+ * (refusing embedded routing for them too is the same fail-closed posture:
204
+ * the user asked for containment and must never get the unprotected in-process
205
+ * tier instead).
206
+ *
207
+ * CONSUMER (required follow-up wiring, outside this wave's file ownership):
208
+ * `planEmbeddedTier` (src/runtime/task-runner/hybrid-tier.ts) must call this
209
+ * FIRST and, when true, emit a visible `tier.decision` warning event (reason
210
+ * "sandbox-opt-in", route subprocess) and return undefined — the task stays
211
+ * on the subprocess tier, which resolveSandboxConfig actually wraps. Until
212
+ * wired, the gate is exported + unit-tested but not yet consulted by the
213
+ * routing seam.
214
+ */
215
+ export function embeddedTierRefusedBySandbox(env: NodeJS.ProcessEnv = process.env): boolean {
216
+ const raw = (getCrewEnv("PI_CREW_SANDBOX", env) ?? "").trim();
217
+ return raw !== "" && raw !== "0";
218
+ }
219
+
220
+ /**
221
+ * Human-visible refusal message for the gate above (single wording shared by
222
+ * the event emission and tests, so the warning cannot drift from the gate).
223
+ */
224
+ export function embeddedTierSandboxRefusalReason(env: NodeJS.ProcessEnv = process.env): string {
225
+ const raw = (getCrewEnv("PI_CREW_SANDBOX", env) ?? "").trim();
226
+ return `Embedded tier refused: PI_CREW_SANDBOX='${raw}' is set — the in-process embedded tier has no bwrap OS boundary and no child-env secret scrub, so routing would void the sandbox opt-in; the task stays on the subprocess tier (which the sandbox wraps)`;
227
+ }
@@ -555,6 +555,15 @@ export function handleDelete(params: TeamToolParamsValue, ctx: ManagementContext
555
555
  if (!params.confirm) return result("delete requires confirm: true.", "error", true);
556
556
  const resolved = resolveMutable(ctx, params);
557
557
  if (resolved.error) return resolved.error;
558
+ // Wave-3 root-gate parity with handleUpdate (w5a,
559
+ // rootGateActionMatrixOmitsDeleteCleanupInit): delete resolves its write
560
+ // tier from the MATCHED resource (params.scope may be undefined and match
561
+ // either tier via the user+project pool), so the facade gate cannot decide
562
+ // — gate HERE on the resolved source. Non-root sessions may only delete
563
+ // project-tier resources; dryRun previews stay behind this gate (fail-
564
+ // closed, matching handleUpdate's gate-before-dryRun order).
565
+ const deleteGate = assertRootOnlyAction("delete", undefined, resolved.resource!.source === "project" ? "project" : "user");
566
+ if (deleteGate) return result(deleteGate.message, "error", true);
558
567
  const refs = findReferences(ctx, params.resource!, resolved.resource!.name);
559
568
  if (refs.length > 0 && !params.force) {
560
569
  return result(
@@ -43,6 +43,7 @@ import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
43
43
  import { applyCrewSettingsTiersToConfig, loadCrewSettingsTiers, scheduledJobsHiddenCountOf } from "../../runtime/settings-store.ts";
44
44
  import type { ReconcileResult } from "../../runtime/stale-reconciler.ts";
45
45
  import { flushReapEscalationsSync } from "../../runtime/stale-reconciler.ts";
46
+ import { getMailboxVerifyKeyPublic } from "../../state/coordination/mailbox.ts";
46
47
  import { loadRunManifestById, saveRunManifest } from "../../state/stores/state-store.ts";
47
48
  import type { TeamRunManifest } from "../../state/types.ts";
48
49
  import { installInlinePanel } from "../../ui/inline-panel/index.ts";
@@ -758,6 +759,22 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
758
759
  let starting: Promise<CrewBroker> | null = null;
759
760
  let cachedSessionId: string | undefined;
760
761
 
762
+ // w1a (coordination-durable-files-no-sender-binding): eagerly create this
763
+ // process's coordination-row signing keypair (heap-only Ed25519) at install
764
+ // time in ROOT sessions, so the parent is a fail-closed verifier from the
765
+ // first mailbox/steering read — before any row it ever signed exists — and
766
+ // children can be handed the public verify key at spawn time
767
+ // (child-pi-spawn → PI_CREW_MAILBOX_VERIFY_KEY). Children install handlers
768
+ // too but never act as coordination writers; creating a keypair there is
769
+ // harmless heap state, but skip it to keep child heaps clean.
770
+ if (isRootSession(process.env)) {
771
+ try {
772
+ getMailboxVerifyKeyPublic();
773
+ } catch {
774
+ /* heap-only keygen must never break extension install */
775
+ }
776
+ }
777
+
761
778
  function effectiveEnabled(): boolean {
762
779
  // Env wins over config. PI_CREW_BROKER=1 forces on, =0 forces off.
763
780
  const envOverride = getCrewEnv("PI_CREW_BROKER");
@@ -860,6 +877,17 @@ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: Re
860
877
 
861
878
  const issueForChild = async (runId: string, taskId?: string, childDepth?: number): Promise<BrokerSpawnCredentials | undefined> => {
862
879
  if (!runId || typeof runId !== "string") return undefined;
880
+ // w1a (broker-bare-run-token-forged-task-identity): FAIL CLOSED on a missing
881
+ // task identity — a spawn without a taskId must NOT receive the legacy bare
882
+ // per-run token. The registry's runId fallback let an LLM worker holding
883
+ // that env-delivered bearer claim ANY queued sibling's identity
884
+ // (cross-task inbox theft + parked-ask hijack, observed end-to-end). This
885
+ // mirrors the detached runner's issuer (background-runner.ts:802 —
886
+ // `if (!taskId) return undefined`). Legit agentId-less scaffold/DWF flows
887
+ // keep working via the explicit credential-less route: the spawn simply
888
+ // carries no broker creds and the worker runs file-paths-only (the
889
+ // pre-broker behavior).
890
+ if (!taskId || typeof taskId !== "string") return undefined;
863
891
  if (!isRootSession(process.env)) return undefined;
864
892
  if (!effectiveEnabled()) return undefined;
865
893
  // ADR-5 §4 (governed nesting): tokens are minted ONLY for children that
@@ -30,6 +30,7 @@ import {
30
30
  savePersistedSubagentRecord,
31
31
  } from "../../runtime/subagent-manager.ts";
32
32
  import { TEAM_TERMINAL_TASK_STATUSES } from "../../state/contracts.ts";
33
+ import { signSteeringEntry } from "../../state/coordination/mailbox.ts";
33
34
  import { resolveEntryBySubagentId, upsertOwnershipEntry } from "../../state/stores/ownership-map.ts";
34
35
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
35
36
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
@@ -558,7 +559,10 @@ export function registerSubagentTools(
558
559
  } catch (error) {
559
560
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
560
561
  }
561
- const line = JSON.stringify({ type: "steer", message: p.message, ts: new Date().toISOString() }) + "\n";
562
+ // w1a sender binding: the steering file is same-uid writable — the
563
+ // line carries this process's Ed25519 signature (worker pollSteering
564
+ // drops unauthenticated lines).
565
+ const line = JSON.stringify(signSteeringEntry({ type: "steer", message: p.message, ts: new Date().toISOString() })) + "\n";
562
566
  if (existingBytes + Buffer.byteLength(line) > MAX_STEERING_BYTES) {
563
567
  return subagentToolResult(
564
568
  `Steering file for task '${taskId}' has reached its size cap (${(MAX_STEERING_BYTES / 1024).toFixed(0)}KiB); steer refused.`,
@@ -23,6 +23,33 @@ export interface ImportedRunBundleInfo {
23
23
  // reading + JSON.parse). Stat BEFORE reading so we never buffer a huge file.
24
24
  export const MAX_IMPORT_BUNDLE_BYTES = 50 * 1024 * 1024;
25
25
 
26
+ /**
27
+ * W7B (imported-manifest-goal-raw-render): output-encoding seam for untrusted
28
+ * strings imported from run bundles (manifest.goal/team/workflow/status,
29
+ * exportedAt, task.error/id/role/agent) before they are rendered into the
30
+ * parent-LLM team-tool listing (lifecycle-actions.ts handleImports) or the
31
+ * durable import README. Bundle files are repo-authored / plant-able content —
32
+ * assertRunBundle validates types only, not content — so every rendered field
33
+ * is forced into inert single-line data:
34
+ * 1. strip well-formed ANSI CSI escape sequences (colors / cursor moves),
35
+ * 2. collapse remaining C0/C1 controls + DEL to spaces (kills forged
36
+ * multi-line listing entries and injected directive lines),
37
+ * 3. strip angle brackets (kills fake <dependency-context> fences and
38
+ * <script> tags),
39
+ * 4. collapse whitespace runs and hard-cap the length.
40
+ * Mirrors the sanitizeFencedBody seam applied to worker output entering parent
41
+ * context (task-output-context.ts).
42
+ */
43
+ export function sanitizeImportedText(value: string | undefined, max = 200): string {
44
+ const stripped = String(value ?? "")
45
+ .replace(/\u001b\[[0-9;?]*[0-9A-Za-z]/g, "")
46
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
47
+ .replace(/[<>]/g, "")
48
+ .replace(/\s+/g, " ")
49
+ .trim();
50
+ return stripped.length > max ? stripped.slice(0, max) : stripped;
51
+ }
52
+
26
53
  function importRoot(cwd: string, scope: "project" | "user"): string {
27
54
  const base = scope === "project" ? projectCrewRoot(cwd) : userCrewRoot();
28
55
  // SECURITY NOTE: `DEFAULT_PATHS.state.importsSubdir` is a constant (not user-controlled).
@@ -157,20 +184,27 @@ export function importRunBundle(cwd: string, bundlePath: string, scope: "project
157
184
  atomicWriteFile(targetJson, `${JSON.stringify({ ...raw, importedAt, importedFrom: resolvedPath }, null, 2)}\n`);
158
185
  atomicWriteFile(
159
186
  targetSummary,
187
+ // W7B: every bundle-authored string below is untrusted (repo/plant
188
+ // content) — render through the sanitizeImportedText seam so control
189
+ // chars, ANSI escapes and angle-bracket fence tokens can never break out
190
+ // of this README into forged headings/directives.
160
191
  [
161
192
  `# Imported pi-crew run ${runId}`,
162
193
  "",
163
194
  `Imported: ${importedAt}`,
164
195
  `Source: ${resolvedPath}`,
165
- `Original export: ${raw.exportedAt}`,
166
- `Status: ${raw.manifest.status}`,
167
- `Team: ${raw.manifest.team}`,
168
- `Workflow: ${raw.manifest.workflow ?? "(none)"}`,
169
- `Goal: ${raw.manifest.goal}`,
196
+ `Original export: ${sanitizeImportedText(raw.exportedAt, 64)}`,
197
+ `Status: ${sanitizeImportedText(raw.manifest.status, 64)}`,
198
+ `Team: ${sanitizeImportedText(raw.manifest.team, 64)}`,
199
+ `Workflow: ${raw.manifest.workflow === undefined ? "(none)" : sanitizeImportedText(raw.manifest.workflow, 64)}`,
200
+ `Goal: ${sanitizeImportedText(raw.manifest.goal)}`,
170
201
  "",
171
202
  "## Tasks",
172
203
  ...raw.tasks.map(
173
- (task) => `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.error ? ` - ${task.error}` : ""}`,
204
+ (task) =>
205
+ `- ${sanitizeImportedText(task.id, 120)}: ${task.status} (${sanitizeImportedText(task.role, 64)} -> ${sanitizeImportedText(task.agent, 64)})${
206
+ typeof task.error === "string" ? ` - ${sanitizeImportedText(task.error)}` : ""
207
+ }`,
174
208
  ),
175
209
  "",
176
210
  ].join("\n"),
@@ -5,6 +5,7 @@
5
5
  * byte-identical to the inline `if` blocks they replace.
6
6
  */
7
7
 
8
+ import { getCrewEnv } from "../../../config/env-vars.ts";
8
9
  import { readCrewAgents } from "../../../runtime/crew-agent-records.ts";
9
10
  import { appendLiveAgentControlRequest } from "../../../runtime/live-session/live-agent-control.ts";
10
11
  import {
@@ -16,6 +17,7 @@ import {
16
17
  stopLiveAgent,
17
18
  } from "../../../runtime/live-session/live-agent-manager.ts";
18
19
  import { liveControlRealtimeMessage, publishLiveControlRealtime } from "../../../runtime/live-session/live-control-realtime.ts";
20
+ import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
19
21
  import {
20
22
  appendFollowUpMessage,
21
23
  appendFollowUpMessageAsync,
@@ -26,6 +28,40 @@ import { appendEvent, appendEventBuffered } from "../../../state/event-log/event
26
28
  import { logInternalError } from "../../../utils/internal-error.ts";
27
29
  import type { ApiOperationHandler } from "./handler-context.ts";
28
30
 
31
+ // ── W1b deferred residual (w9-r1): role gate on the live-agent control cluster ──
32
+ // Every operation dispatched to handleLiveAgentControl (steer-agent,
33
+ // follow-up-agent, stop-agent, resume-agent, interrupt-agent) mutates run
34
+ // state — mailbox writes, live-control request files, session stop/resume —
35
+ // so the whole shared handler is gated with the canMutateTaskClaims pattern
36
+ // (task-claims.ts): read_only-tier roles (explorer/analyst/reviewer/critic/
37
+ // planner/security-reviewer + default-deny unknowns) are denied; write-tier
38
+ // workers and role-less root-session callers stay allowed. handleListLiveAgents
39
+ // is a pure read and stays open. NOTE: the separately named `foreground-interrupt`
40
+ // operation lives in read.ts (outside this file's ownership) — see the w9-r1
41
+ // fix record for the ownership-handoff note.
42
+
43
+ /** Role-tier gate for live-agent control mutations (canApprovePlan pattern). */
44
+ function canControlLiveAgents(): { allowed: boolean; reason?: string } {
45
+ const role = currentCrewRole();
46
+ if (!role) return { allowed: true };
47
+ if (permissionForRole(role) === "read_only")
48
+ return {
49
+ allowed: false,
50
+ reason: `Role '${role}' is read-only and cannot steer, stop, resume, or interrupt agents.`,
51
+ };
52
+ return { allowed: true };
53
+ }
54
+
55
+ /** Caller identity fields for the audit trail (w1b callerAuditFields pattern). */
56
+ function callerAuditFields(): { callerRole?: string; callerTaskId?: string } {
57
+ const callerRole = currentCrewRole();
58
+ const callerTaskId = getCrewEnv("PI_CREW_TASK_ID")?.trim() || undefined;
59
+ return {
60
+ ...(callerRole ? { callerRole } : {}),
61
+ ...(callerTaskId ? { callerTaskId } : {}),
62
+ };
63
+ }
64
+
29
65
  export const handleNudgeAgent: ApiOperationHandler = (hctx) => {
30
66
  const { cfg, loaded, result, paramRequired, ctx } = hctx;
31
67
  const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
@@ -109,6 +145,17 @@ export const handleListLiveAgents: ApiOperationHandler = (hctx) => {
109
145
  */
110
146
  export const handleLiveAgentControl: ApiOperationHandler = async (hctx) => {
111
147
  const { cfg, loaded, result, paramRequired, ctx } = hctx;
148
+ const gate = canControlLiveAgents();
149
+ if (!gate.allowed)
150
+ return result(
151
+ gate.reason ?? "agent control is not allowed in this context.",
152
+ {
153
+ action: "api",
154
+ status: "error",
155
+ runId: loaded.manifest.runId,
156
+ },
157
+ true,
158
+ );
112
159
  const operation = typeof cfg.operation === "string" ? cfg.operation : "";
113
160
  const agentId = typeof cfg.agentId === "string" ? cfg.agentId : undefined;
114
161
  if (!agentId)
@@ -330,6 +377,8 @@ export const handleLiveAgentControl: ApiOperationHandler = async (hctx) => {
330
377
  request,
331
378
  mailboxMessageId: mailboxMessage?.id,
332
379
  realtime: true,
380
+ // W1b residual: record the verified caller identity on the audit trail.
381
+ ...callerAuditFields(),
333
382
  },
334
383
  }).catch((e) => logInternalError("api.agent-control.buffered", e, "type=agent.control.queued"));
335
384
  return result(JSON.stringify({ queued: true, request, mailboxMessage }, null, 2), {
@@ -5,7 +5,9 @@
5
5
  * byte-identical to the inline `if` blocks they replace.
6
6
  */
7
7
 
8
+ import { getCrewEnv } from "../../../config/env-vars.ts";
8
9
  import { touchWorkerHeartbeat } from "../../../runtime/heartbeat/worker-heartbeat.ts";
10
+ import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
9
11
  import { isTerminalTaskStatus } from "../../../state/contracts.ts";
10
12
  import { withRunLockSync } from "../../../state/coordination/locks.ts";
11
13
  import { appendEventBuffered } from "../../../state/event-log/event-log.ts";
@@ -14,8 +16,49 @@ import { logInternalError } from "../../../utils/internal-error.ts";
14
16
  import { RUN_NOT_FOUND_HINT } from "../run-not-found.ts";
15
17
  import type { ApiOperationHandler } from "./handler-context.ts";
16
18
 
19
+ // ── W1b deferred residual (w9-r1): role gate on write-heartbeat ──
20
+ // write-heartbeat mutates run state (task.heartbeat on disk + worker.heartbeat
21
+ // event) and drives liveness/watchdog decisions, so it follows the
22
+ // canMutateTaskClaims pattern (task-claims.ts): read_only-tier roles
23
+ // (explorer/analyst/reviewer/critic/planner/security-reviewer + default-deny
24
+ // unknowns) are denied; write-tier workers and role-less root-session callers
25
+ // stay allowed.
26
+
27
+ /** Role-tier gate for heartbeat-mutating operations (canApprovePlan pattern). */
28
+ function canWriteHeartbeat(): { allowed: boolean; reason?: string } {
29
+ const role = currentCrewRole();
30
+ if (!role) return { allowed: true };
31
+ if (permissionForRole(role) === "read_only")
32
+ return {
33
+ allowed: false,
34
+ reason: `Role '${role}' is read-only and cannot write worker heartbeats.`,
35
+ };
36
+ return { allowed: true };
37
+ }
38
+
39
+ /** Caller identity fields for the audit trail (w1b callerAuditFields pattern). */
40
+ function callerAuditFields(): { callerRole?: string; callerTaskId?: string } {
41
+ const callerRole = currentCrewRole();
42
+ const callerTaskId = getCrewEnv("PI_CREW_TASK_ID")?.trim() || undefined;
43
+ return {
44
+ ...(callerRole ? { callerRole } : {}),
45
+ ...(callerTaskId ? { callerTaskId } : {}),
46
+ };
47
+ }
48
+
17
49
  export const handleWriteHeartbeat: ApiOperationHandler = (hctx) => {
18
50
  const { cfg, loaded, result, paramRequired } = hctx;
51
+ const gate = canWriteHeartbeat();
52
+ if (!gate.allowed)
53
+ return result(
54
+ gate.reason ?? "write-heartbeat is not allowed in this context.",
55
+ {
56
+ action: "api",
57
+ status: "error",
58
+ runId: loaded.manifest.runId,
59
+ },
60
+ true,
61
+ );
19
62
  const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
20
63
  const task = loaded.tasks.find((item) => item.id === taskId || item.stepId === taskId);
21
64
  if (!task)
@@ -84,7 +127,11 @@ export const handleWriteHeartbeat: ApiOperationHandler = (hctx) => {
84
127
  type: "worker.heartbeat",
85
128
  runId: fresh.manifest.runId,
86
129
  taskId: freshTask.id,
87
- data: { ...heartbeat },
130
+ data: {
131
+ ...heartbeat,
132
+ // W1b residual: record the verified caller identity on the audit trail.
133
+ ...callerAuditFields(),
134
+ },
88
135
  }).catch((e) => logInternalError("api.heartbeat.buffered", e, "type=worker.heartbeat"));
89
136
  return result(JSON.stringify(heartbeat, null, 2), {
90
137
  action: "api",