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
@@ -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
@@ -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(","));
@@ -22,6 +22,7 @@
22
22
  import * as fs from "node:fs";
23
23
  import * as path from "node:path";
24
24
  import { withRunLockSync } from "../state/coordination/locks.ts";
25
+ import { signSteeringEntry } from "../state/coordination/mailbox.ts";
25
26
  import { appendEvent } from "../state/event-log/event-log.ts";
26
27
  import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
27
28
  import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../state/stores/state-store.ts";
@@ -56,7 +57,9 @@ function appendSteeringAdvisory(manifest: TeamRunManifest, taskId: string): bool
56
57
  }
57
58
  fs.appendFileSync(
58
59
  safePath,
59
- `${JSON.stringify({ type: "steer", message: WRAP_UP_ADVISORY, ts: new Date().toISOString() })}\n`,
60
+ // w1a sender binding: sign the advisory line (steering file is same-uid
61
+ // writable; worker pollSteering drops unauthenticated lines).
62
+ `${JSON.stringify(signSteeringEntry({ type: "steer", message: WRAP_UP_ADVISORY, ts: new Date().toISOString() }))}\n`,
60
63
  "utf-8",
61
64
  );
62
65
  return true;
@@ -31,6 +31,7 @@ import { loadConfig } from "../../config/config.ts";
31
31
  import { getCrewEnv, getCrewEnvInt } from "../../config/env-vars.ts";
32
32
  import { errors } from "../../errors.ts";
33
33
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
34
+ import { signSteeringEntry } from "../../state/coordination/mailbox.ts";
34
35
  import { appendEventAsync, appendEventBuffered } from "../../state/event-log/event-log.ts";
35
36
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
36
37
  import { upsertOwnershipEntry } from "../../state/stores/ownership-map.ts";
@@ -86,14 +87,19 @@ async function appendSteeringAsync(steeringDir: string, taskId: string, steers:
86
87
  try {
87
88
  await fs.promises.mkdir(steeringDir, { recursive: true });
88
89
  const steeringPath = resolveRealContainedPath(steeringDir, `${taskId}.jsonl`);
90
+ // w1a sender binding: the steering file is same-uid writable — each line
91
+ // carries this process's Ed25519 signature (worker pollSteering drops
92
+ // unauthenticated lines).
89
93
  const lines = steers
90
94
  .map(
91
95
  (msg) =>
92
- JSON.stringify({
93
- type: "steer",
94
- message: msg,
95
- ts: new Date().toISOString(),
96
- }) + "\n",
96
+ JSON.stringify(
97
+ signSteeringEntry({
98
+ type: "steer",
99
+ message: msg,
100
+ ts: new Date().toISOString(),
101
+ }),
102
+ ) + "\n",
97
103
  )
98
104
  .join("");
99
105
  await fs.promises.appendFile(steeringPath, lines, "utf-8");
@@ -46,6 +46,7 @@
46
46
  */
47
47
 
48
48
  import type { CrewRuntimeConfig } from "../../config/config.ts";
49
+ import { embeddedTierRefusedBySandbox, embeddedTierSandboxRefusalReason } from "../../config/sanitize-project-config.ts";
49
50
  import { flushPendingAtomicWrites } from "../../state/atomic-write.ts";
50
51
  import { appendEventFireAndForget } from "../../state/event-log/event-log.ts";
51
52
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
@@ -180,6 +181,22 @@ export function planEmbeddedTier(input: PlanEmbeddedTierInput): EmbeddedTierPlan
180
181
  const threshold = resolveEmbeddedFaultThreshold(policy);
181
182
  const env = input.env ?? process.env;
182
183
  const { manifest } = input;
184
+ // w4b defense-in-depth wiring (SEC-2026-11 embeddedWorkers.project-tier-routing):
185
+ // a live sandbox posture (PI_CREW_SANDBOX=bwrap) means the user opted into an
186
+ // OS boundary around workers. The embedded tier runs IN-PROCESS with neither
187
+ // the bwrap boundary nor the child-env secret scrub, so routing would void
188
+ // that opt-in. Fail closed: refuse embedded routing (subprocess tier keeps
189
+ // the sandbox), with a visible tier.decision warning.
190
+ if (embeddedTierRefusedBySandbox(env)) {
191
+ appendEventFireAndForget(manifest.eventsPath, {
192
+ type: "tier.decision",
193
+ runId: manifest.runId,
194
+ taskId: input.task.id,
195
+ message: embeddedTierSandboxRefusalReason(env),
196
+ data: { route: "subprocess", to: input.baseRuntimeKind, reason: "sandbox-opt-in" },
197
+ });
198
+ return undefined;
199
+ }
183
200
  const resolved = resolveTaskRuntimeKind(input.baseRuntimeKind, input.role, input.isolationPolicy, env, {
184
201
  policy,
185
202
  faultCount,
@@ -272,19 +272,26 @@ export async function prepareTaskExecutionContext(
272
272
 
273
273
  // Deterministic pre-step: run script, inject stdout into worker prompt
274
274
  let preStepOutput: string | undefined;
275
- if (input.step.preStepScript && input.step.source !== "builtin" && input.step.source !== "user") {
276
- // F-02 SECURITY FIX (allowlist): only builtin/user-sourced workflows may
277
- // execute pre-step scripts. Project-sourced AND programmatic
275
+ if (input.step.preStepScript && input.step.source !== "builtin") {
276
+ // F-02 SECURITY FIX (allowlist, w3-dwf-trust tightening): only builtin-sourced
277
+ // workflows may execute pre-step scripts. Project-sourced AND programmatic
278
278
  // (source=undefined) steps are denied — a hostile repo clone could embed
279
279
  // arbitrary code via preStepScript + execFileSync, and steps constructed
280
280
  // without explicit trusted provenance should not auto-trust file
281
- // execution. Deny by default; discover-workflows strips project scripts
282
- // upstream so legitimate builtin/user scripts still run.
281
+ // execution. The 'user' tier was REMOVED from the allowlist (w3-dwf-trust,
282
+ // 2026-10-29): ~/.pi/agent/workflows is writable by every WRITE-role child
283
+ // worker (same OS uid), so a planted user-tier *.workflow.md carrying a
284
+ // preStepScript previously executed it via execFileSync in this HOST
285
+ // process (F-02 only stripped project scripts at the discover layer).
286
+ // Deny by default; re-introduce via an explicit dispatch-time hash pin if a
287
+ // legitimate user-tier pre-step need appears (see
288
+ // TeamRunManifest.dynamicScriptPin). discover-workflows strips project
289
+ // scripts upstream so legitimate builtin scripts still run.
283
290
  appendEventFireAndForget(manifest.eventsPath, {
284
291
  type: "hook.pre_step_skipped",
285
292
  runId: manifest.runId,
286
293
  taskId: task.id,
287
- message: `preStepScript '${input.step.preStepScript}' skipped: only builtin/user-sourced workflows may execute pre-step scripts for security (F-02).`,
294
+ message: `preStepScript '${input.step.preStepScript}' skipped: only builtin-sourced workflows may execute pre-step scripts for security (F-02; user tier tightened in w3-dwf-trust — user config dirs are writable by same-uid workers).`,
288
295
  data: { script: input.step.preStepScript, source: input.step.source ?? "unknown" },
289
296
  });
290
297
  preStepOutput = undefined;
@@ -182,6 +182,20 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
182
182
  // package.json. Absent (old manifests / goal-loop runs) ⇒ the gate
183
183
  // resolves no checks ⇒ INCONCLUSIVE ⇒ verifier spawns (fail-closed).
184
184
  ...(manifest.verifyGateSpec ? { hostSpec: manifest.verifyGateSpec } : {}),
185
+ // W2 (worktree-verifier-gate-excludes-work): a green gate in THIS
186
+ // task's tree must never certify dependency work it cannot see —
187
+ // probe each dependency task's worktree; uncommitted work there
188
+ // forces the LLM verifier spawn even on a PASS verdict (reason
189
+ // "unverified_worktree_work"). Shared-cwd deps (no worktree) need
190
+ // no probe — their work is inside the gate's own cwd.
191
+ ...(ctx.task.dependsOn.length > 0
192
+ ? {
193
+ dependencyWorktreeProbes: ctx.task.dependsOn.map((depId) => ({
194
+ taskId: depId,
195
+ worktreePath: ctx.tasks.find((t) => t.id === depId)?.worktree?.path,
196
+ })),
197
+ }
198
+ : {}),
185
199
  signal: input.signal,
186
200
  classifier: {
187
201
  enabled: resolveClassifierEnabled(input.runtimeConfig?.classifierEnabled),
@@ -158,6 +158,16 @@ function startPerfSampler(manifest: TeamRunManifest, team: TeamConfig, signal?:
158
158
  { detached: true, stdio: ["ignore", "ignore", "pipe"], signal },
159
159
  );
160
160
  // diagnostics: sampler stderr → perf-obs.log (best-effort; never affects the run)
161
+ // Abort-safety (B6 mirror, child-pi.ts taskkill): the sampler is spawned
162
+ // WITH the run AbortSignal (R11-4 teardown-kill) but detached+unref'd, so it
163
+ // can exit BEFORE the signal fires — Node's spawn-signal listener then
164
+ // throws AbortError synchronously AND emits 'error' on the ChildProcess;
165
+ // without a listener that emission is an uncaught exception that kills the
166
+ // host (observed: run-deadline abort ~300ms into a resumed run raced the
167
+ // sampler's exit). The listener keeps abort-after-exit bounded.
168
+ child.on("error", (err: Error) => {
169
+ logInternalError("team-runner.perf-sampler.child-error", err, `runId=${manifest.runId}`, "warn");
170
+ });
161
171
  child.stderr?.on("data", (d: Buffer) => {
162
172
  try {
163
173
  fs.appendFileSync(logPath, String(d));
@@ -382,6 +392,22 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
382
392
  { allowTerminalExit: input.isResume === true },
383
393
  );
384
394
 
395
+ // Manifest-derived re-arm (resume-disarms-run-budget-enforcement fix): a caller
396
+ // that reconstructs the input without budget fields (handleResume static-path
397
+ // re-entry) must not silently disarm the operator's persisted spend cap —
398
+ // enforceRunBudget gates solely on ctx.input.budgetTotal (budget-enforcement.ts)
399
+ // and the manifest budget is persisted input→manifest only, so persistence
400
+ // alone never re-arms enforcement on a later execution. Seed any missing input
401
+ // budget fields from the loaded manifest BEFORE the persist block so EVERY
402
+ // execution of the same run keeps its declared bounds regardless of caller
403
+ // diligence. Caller-supplied values (dispatch paths forward params.budget*,
404
+ // detached re-entry forwards manifest.budget*) always win — `??=` never
405
+ // overrides an explicitly forwarded field.
406
+ input.budgetTotal ??= manifest.budgetTotal;
407
+ input.budgetWarning ??= manifest.budgetWarning;
408
+ input.budgetAbort ??= manifest.budgetAbort;
409
+ input.budgetUnlimited ??= manifest.budgetUnlimited;
410
+
385
411
  // Persist budget fields on the manifest so all subsequent saveRunManifest
386
412
  // calls (there are many in executeTeamRunCore) preserve the budget config.
387
413
  // Without this, the spread in updateRunStatus and other transformations