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
@@ -5,6 +5,8 @@
5
5
  * byte-identical to the inline `if` blocks they replace.
6
6
  */
7
7
 
8
+ import { getCrewEnv } from "../../../config/env-vars.ts";
9
+ import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
8
10
  import { withRunLockSync } from "../../../state/coordination/locks.ts";
9
11
  import {
10
12
  acknowledgeMailboxMessage,
@@ -20,6 +22,35 @@ import { appendEvent, appendEventBuffered } from "../../../state/event-log/event
20
22
  import { logInternalError } from "../../../utils/internal-error.ts";
21
23
  import type { ApiOperationHandler } from "./handler-context.ts";
22
24
 
25
+ // ── W1b deferred residual (w9-r1): role gates on mutating mailbox operations ──
26
+ // send-message (mailbox append + event), ack-message (delivery-state write +
27
+ // events), and validate-mailbox WITH repair=true (rewrites mailbox/delivery
28
+ // files) all mutate run state, so they follow the canMutateTaskClaims pattern
29
+ // (task-claims.ts): read_only-tier roles (explorer/analyst/reviewer/critic/
30
+ // planner/security-reviewer + default-deny unknowns) are denied; write-tier
31
+ // workers and role-less root-session callers stay allowed. Plain validate-mailbox
32
+ // (repair unset/false) is a pure diagnostic READ and stays open, as do
33
+ // read-mailbox and read-delivery.
34
+
35
+ /** Role-tier gate for mailbox-mutating operations (canApprovePlan pattern). */
36
+ function canMutateMailbox(op: "repair" | "send" | "ack"): { allowed: boolean; reason?: string } {
37
+ const role = currentCrewRole();
38
+ if (!role) return { allowed: true };
39
+ if (permissionForRole(role) !== "read_only") return { allowed: true };
40
+ const action = op === "repair" ? "repair the mailbox" : op === "send" ? "send mailbox messages" : "acknowledge mailbox messages";
41
+ return { allowed: false, reason: `Role '${role}' is read-only and cannot ${action}.` };
42
+ }
43
+
44
+ /** Caller identity fields for the audit trail (w1b callerAuditFields pattern). */
45
+ function callerAuditFields(): { callerRole?: string; callerTaskId?: string } {
46
+ const callerRole = currentCrewRole();
47
+ const callerTaskId = getCrewEnv("PI_CREW_TASK_ID")?.trim() || undefined;
48
+ return {
49
+ ...(callerRole ? { callerRole } : {}),
50
+ ...(callerTaskId ? { callerTaskId } : {}),
51
+ };
52
+ }
53
+
23
54
  export const handleReadMailbox: ApiOperationHandler = (hctx) => {
24
55
  const { cfg, loaded, result } = hctx;
25
56
  const direction = cfg.direction === "inbox" || cfg.direction === "outbox" ? (cfg.direction as MailboxDirection) : undefined;
@@ -61,6 +92,21 @@ export const handleReadMailbox: ApiOperationHandler = (hctx) => {
61
92
 
62
93
  export const handleValidateMailbox: ApiOperationHandler = (hctx) => {
63
94
  const { cfg, loaded, result } = hctx;
95
+ // W1b residual: ONLY the repair pass mutates (rewrites mailbox/delivery
96
+ // state); the plain validation report is a diagnostic read and stays open.
97
+ if (cfg.repair === true) {
98
+ const gate = canMutateMailbox("repair");
99
+ if (!gate.allowed)
100
+ return result(
101
+ gate.reason ?? "mailbox repair is not allowed in this context.",
102
+ {
103
+ action: "api",
104
+ status: "error",
105
+ runId: loaded.manifest.runId,
106
+ },
107
+ true,
108
+ );
109
+ }
64
110
  const report = validateMailbox(loaded.manifest, {
65
111
  repair: cfg.repair === true,
66
112
  });
@@ -88,6 +134,17 @@ export const handleReadDelivery: ApiOperationHandler = (hctx) => {
88
134
 
89
135
  export const handleSendMessage: ApiOperationHandler = (hctx) => {
90
136
  const { cfg, loaded, result, paramRequired, ctx } = hctx;
137
+ const gate = canMutateMailbox("send");
138
+ if (!gate.allowed)
139
+ return result(
140
+ gate.reason ?? "send-message is not allowed in this context.",
141
+ {
142
+ action: "api",
143
+ status: "error",
144
+ runId: loaded.manifest.runId,
145
+ },
146
+ true,
147
+ );
91
148
  const direction = cfg.direction === "outbox" ? "outbox" : "inbox";
92
149
  const from = typeof cfg.from === "string" && cfg.from.trim() ? cfg.from.trim() : "api";
93
150
  const to = typeof cfg.to === "string" && cfg.to.trim() ? cfg.to.trim() : "leader";
@@ -133,7 +190,14 @@ export const handleSendMessage: ApiOperationHandler = (hctx) => {
133
190
  appendEventBuffered(loaded.manifest.eventsPath, {
134
191
  type: "mailbox.message",
135
192
  runId: loaded.manifest.runId,
136
- data: { id: message.id, direction, from, to },
193
+ data: {
194
+ id: message.id,
195
+ direction,
196
+ from,
197
+ to,
198
+ // W1b residual: record the verified caller identity on the audit trail.
199
+ ...callerAuditFields(),
200
+ },
137
201
  }).catch((e) => logInternalError("api.mailbox.buffered", e, "type=mailbox.message"));
138
202
  ctx.events?.emit?.("crew.mailbox.message", {
139
203
  runId: loaded.manifest.runId,
@@ -167,6 +231,17 @@ export const handleSendMessage: ApiOperationHandler = (hctx) => {
167
231
 
168
232
  export const handleAckMessage: ApiOperationHandler = (hctx) => {
169
233
  const { cfg, loaded, result, paramRequired, ctx } = hctx;
234
+ const gate = canMutateMailbox("ack");
235
+ if (!gate.allowed)
236
+ return result(
237
+ gate.reason ?? "ack-message is not allowed in this context.",
238
+ {
239
+ action: "api",
240
+ status: "error",
241
+ runId: loaded.manifest.runId,
242
+ },
243
+ true,
244
+ );
170
245
  const messageId = typeof cfg.messageId === "string" ? cfg.messageId : undefined;
171
246
  if (!messageId)
172
247
  return result(
@@ -194,7 +269,11 @@ export const handleAckMessage: ApiOperationHandler = (hctx) => {
194
269
  appendEvent(loaded.manifest.eventsPath, {
195
270
  type: "mailbox.acknowledged",
196
271
  runId: loaded.manifest.runId,
197
- data: { messageId },
272
+ data: {
273
+ messageId,
274
+ // W1b residual: record the verified caller identity on the audit trail.
275
+ ...callerAuditFields(),
276
+ },
198
277
  });
199
278
  } catch (e) {
200
279
  logInternalError("api.mailbox.append", e, "type=mailbox.acknowledged");
@@ -15,6 +15,7 @@ import { agentOutputPath, readCrewAgentEventsCursor, readCrewAgentStatus, readCr
15
15
  import { readForegroundControlStatus, writeForegroundInterruptRequest } from "../../../runtime/foreground-control.ts";
16
16
  import { probeLiveSessionRuntime } from "../../../runtime/live-session/live-session-runtime.ts";
17
17
  import { resolveCrewRuntime } from "../../../runtime/model/runtime-resolver.ts";
18
+ import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
18
19
  import { fenceWorkerText } from "../../../runtime/sanitize/worker-text.ts";
19
20
  import { readEvents, readEventsCursor } from "../../../state/event-log/event-log.ts";
20
21
  import { globMatch } from "../../../utils/glob-match.ts";
@@ -357,8 +358,29 @@ export const handleForegroundStatus: ApiOperationHandler = (hctx) => {
357
358
  });
358
359
  };
359
360
 
361
+ /** w9-r1 follow-up: role-tier gate for the mutating foreground-interrupt op
362
+ * (canMutateMailbox pattern — the op WRITES an interrupt-request file, it is
363
+ * not a read despite living in the read group). */
364
+ function canWriteForegroundInterrupt(): { allowed: boolean; reason?: string } {
365
+ const role = currentCrewRole();
366
+ if (!role) return { allowed: true };
367
+ if (permissionForRole(role) !== "read_only") return { allowed: true };
368
+ return { allowed: false, reason: `Role '${role}' is read-only and cannot request a foreground interrupt.` };
369
+ }
370
+
360
371
  export const handleForegroundInterrupt: ApiOperationHandler = (hctx) => {
361
372
  const { cfg, loaded, result } = hctx;
373
+ const gate = canWriteForegroundInterrupt();
374
+ if (!gate.allowed)
375
+ return result(
376
+ gate.reason ?? "foreground interrupt is not allowed in this context.",
377
+ {
378
+ action: "api",
379
+ status: "error",
380
+ runId: loaded.manifest.runId,
381
+ },
382
+ true,
383
+ );
362
384
  const reason = typeof cfg.reason === "string" && cfg.reason.trim() ? cfg.reason.trim() : undefined;
363
385
  return result(JSON.stringify(writeForegroundInterruptRequest(loaded.manifest, reason), null, 2), {
364
386
  action: "api",
@@ -13,6 +13,8 @@
13
13
  * `test/unit/extension/core/api-claim.test.ts` suite guards against drift.
14
14
  */
15
15
 
16
+ import { getCrewEnv } from "../../../config/env-vars.ts";
17
+ import { currentCrewRole, permissionForRole } from "../../../runtime/role-permission.ts";
16
18
  import { canTransitionTaskStatus, isTeamTaskStatus } from "../../../state/contracts.ts";
17
19
  import { withRunLockSync } from "../../../state/coordination/locks.ts";
18
20
  import { claimTask, releaseTaskClaim, transitionClaimedTaskStatus } from "../../../state/coordination/task-claims.ts";
@@ -23,6 +25,80 @@ import { logInternalError } from "../../../utils/internal-error.ts";
23
25
  import { RUN_NOT_FOUND_HINT } from "../run-not-found.ts";
24
26
  import type { ApiHandlerContext, ApiOperationHandler } from "./handler-context.ts";
25
27
 
28
+ // ── W1b caller binding (security fix team-tool/api.claim-task.missing-caller-role-ownership-binding) ──
29
+ // claim-task mints the capability token that release-task-claim and
30
+ // transition-task-status later trust, so whoever can claim can poison a run's
31
+ // task lifecycle (queued→failed flips drive selectDispatchBatch/finalization).
32
+ // Two bindings close that, following the canApprovePlan pattern
33
+ // (plan-approval.ts):
34
+ // 1. ROLE GATE — read_only-tier callers (explorer/analyst/reviewer/critic/
35
+ // planner/security-reviewer + default-deny unknowns) are denied on all
36
+ // three claim-mutating operations. Root-session callers (no PI_CREW_ROLE)
37
+ // stay allowed — they are the tool's intended principals.
38
+ // 2. IDENTITY BINDING (claim only) — a caller WITH a worker task identity
39
+ // (PI_CREW_TASK_ID, set for spawned workers) may only claim its OWN task,
40
+ // and the claim owner is forced to that verified identity instead of the
41
+ // self-declared cfg.owner. Release/transition stay token-gated: since the
42
+ // token can only be minted for the caller's own identity, a foreign
43
+ // (owner, token) pair cannot be constructed.
44
+
45
+ /** Role-tier gate for claim-mutating operations (canApprovePlan pattern). */
46
+ function canMutateTaskClaims(): { allowed: boolean; reason?: string } {
47
+ const role = currentCrewRole();
48
+ if (!role) return { allowed: true };
49
+ if (permissionForRole(role) === "read_only")
50
+ return {
51
+ allowed: false,
52
+ reason: `Role '${role}' is read-only and cannot claim, release, or transition tasks.`,
53
+ };
54
+ return { allowed: true };
55
+ }
56
+
57
+ /** The caller's own registered task identity (set for spawned workers; unset
58
+ * for the root session). Read via the env registry — never raw process.env. */
59
+ function callerTaskIdentity(): string | undefined {
60
+ return getCrewEnv("PI_CREW_TASK_ID")?.trim() || undefined;
61
+ }
62
+
63
+ /** Caller identity fields for the audit trail of claim-mutating events. */
64
+ function callerAuditFields(): { callerRole?: string; callerTaskId?: string } {
65
+ const callerRole = currentCrewRole();
66
+ const callerTaskId = callerTaskIdentity();
67
+ return {
68
+ ...(callerRole ? { callerRole } : {}),
69
+ ...(callerTaskId ? { callerTaskId } : {}),
70
+ };
71
+ }
72
+
73
+ type ClaimOwnerBinding = { allowed: true; owner: string } | { allowed: false; reason: string };
74
+
75
+ /**
76
+ * Bind claim-task to the caller's own identity. A caller with a worker task
77
+ * identity may only claim the task registered under that identity (validated
78
+ * against the run's task registry by the caller's existing find/fresh-re-read
79
+ * path — a foreign id fails the required-task lookup) and an EXPLICITLY
80
+ * declared cfg.owner must match it; the effective owner becomes the verified
81
+ * caller identity (a defaulted owner is silently replaced by it, never
82
+ * treated as a declaration). Root-session callers (no worker identity) keep
83
+ * the previous owner semantics unchanged.
84
+ */
85
+ function bindClaimToCaller(taskId: string | undefined, declaredOwner: string | undefined): ClaimOwnerBinding {
86
+ const callerTaskId = callerTaskIdentity();
87
+ if (!callerTaskId) return { allowed: true, owner: declaredOwner ?? "api" };
88
+ if (!taskId) return { allowed: true, owner: declaredOwner ?? "api" }; // missing-param path reports below
89
+ if (taskId !== callerTaskId)
90
+ return {
91
+ allowed: false,
92
+ reason: `claim-task rejected: caller task '${callerTaskId}' may only claim its own task — refusing cross-task claim on '${taskId}'.`,
93
+ };
94
+ if (declaredOwner !== undefined && declaredOwner !== callerTaskId)
95
+ return {
96
+ allowed: false,
97
+ reason: `claim-task rejected: declared owner '${declaredOwner}' does not match the caller's own task identity '${callerTaskId}'.`,
98
+ };
99
+ return { allowed: true, owner: callerTaskId };
100
+ }
101
+
26
102
  /** Find a task by id OR stepId — the convention used by every claim op. */
27
103
  function findTaskByIdOrStepId(tasks: TeamTaskState[], taskId: string | undefined) {
28
104
  if (!taskId) return undefined;
@@ -56,8 +132,30 @@ function freshRunOrMissing(
56
132
  /** handleApi operation: `claim-task`. */
57
133
  export const handleClaimTask: ApiOperationHandler = (ctx) => {
58
134
  const { cfg, loaded, result, paramRequired } = ctx;
135
+ const gate = canMutateTaskClaims();
136
+ if (!gate.allowed)
137
+ return result(
138
+ gate.reason ?? "claim-task is not allowed in this context.",
139
+ {
140
+ action: "api",
141
+ status: "error",
142
+ runId: loaded.manifest.runId,
143
+ },
144
+ true,
145
+ );
59
146
  const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
60
- const owner = typeof cfg.owner === "string" ? cfg.owner : "api";
147
+ const binding = bindClaimToCaller(taskId, typeof cfg.owner === "string" ? cfg.owner : undefined);
148
+ if (!binding.allowed)
149
+ return result(
150
+ binding.reason,
151
+ {
152
+ action: "api",
153
+ status: "error",
154
+ runId: loaded.manifest.runId,
155
+ },
156
+ true,
157
+ );
158
+ const owner = binding.owner;
61
159
  const task = findTaskByIdOrStepId(loaded.tasks, taskId);
62
160
  if (!task)
63
161
  return result(
@@ -101,6 +199,8 @@ export const handleClaimTask: ApiOperationHandler = (ctx) => {
101
199
  owner,
102
200
  token: "[REDACTED]",
103
201
  leasedUntil: updatedTask.claim?.leasedUntil,
202
+ // W1b: record the verified caller identity on the audit trail.
203
+ ...callerAuditFields(),
104
204
  },
105
205
  }).catch((e) => logInternalError("api.task-claims.buffered", e, "type=task.claimed"));
106
206
  return result(JSON.stringify(updatedTask.claim, null, 2), {
@@ -127,6 +227,17 @@ export const handleClaimTask: ApiOperationHandler = (ctx) => {
127
227
  /** handleApi operation: `release-task-claim`. */
128
228
  export const handleReleaseTaskClaim: ApiOperationHandler = (ctx) => {
129
229
  const { cfg, loaded, result, paramRequired } = ctx;
230
+ const gate = canMutateTaskClaims();
231
+ if (!gate.allowed)
232
+ return result(
233
+ gate.reason ?? "release-task-claim is not allowed in this context.",
234
+ {
235
+ action: "api",
236
+ status: "error",
237
+ runId: loaded.manifest.runId,
238
+ },
239
+ true,
240
+ );
130
241
  const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
131
242
  const owner = typeof cfg.owner === "string" ? cfg.owner : undefined;
132
243
  const token = typeof cfg.token === "string" ? cfg.token : undefined;
@@ -167,7 +278,11 @@ export const handleReleaseTaskClaim: ApiOperationHandler = (ctx) => {
167
278
  type: "task.claim_released",
168
279
  runId: fresh.manifest.runId,
169
280
  taskId: freshTask.id,
170
- data: { owner },
281
+ data: {
282
+ owner,
283
+ // W1b: record the verified caller identity on the audit trail.
284
+ ...callerAuditFields(),
285
+ },
171
286
  }).catch((e) => logInternalError("api.task-claims.buffered", e, "type=task.claim_released"));
172
287
  return result(JSON.stringify(updatedTask, null, 2), {
173
288
  action: "api",
@@ -193,6 +308,17 @@ export const handleReleaseTaskClaim: ApiOperationHandler = (ctx) => {
193
308
  /** handleApi operation: `transition-task-status`. */
194
309
  export const handleTransitionTaskStatus: ApiOperationHandler = (ctx) => {
195
310
  const { cfg, loaded, result, paramRequired } = ctx;
311
+ const gate = canMutateTaskClaims();
312
+ if (!gate.allowed)
313
+ return result(
314
+ gate.reason ?? "transition-task-status is not allowed in this context.",
315
+ {
316
+ action: "api",
317
+ status: "error",
318
+ runId: loaded.manifest.runId,
319
+ },
320
+ true,
321
+ );
196
322
  const taskId = typeof cfg.taskId === "string" ? cfg.taskId : undefined;
197
323
  const owner = typeof cfg.owner === "string" ? cfg.owner : undefined;
198
324
  const token = typeof cfg.token === "string" ? cfg.token : undefined;
@@ -246,7 +372,12 @@ export const handleTransitionTaskStatus: ApiOperationHandler = (ctx) => {
246
372
  type: "task.status_transitioned",
247
373
  runId: fresh.manifest.runId,
248
374
  taskId: freshTask.id,
249
- data: { owner, status: to },
375
+ data: {
376
+ owner,
377
+ status: to,
378
+ // W1b: record the verified caller identity on the audit trail.
379
+ ...callerAuditFields(),
380
+ },
250
381
  }).catch((e) => logInternalError("api.task-claims.buffered", e, "type=task.status_transitioned"));
251
382
  return result(JSON.stringify(updatedTask, null, 2), {
252
383
  action: "api",
@@ -92,6 +92,17 @@ export function shouldBlockDestructiveTeamAction(action: string | undefined, inp
92
92
  * - `create` / — when resolved scope is `user` (create defaults to user;
93
93
  * - `update` update is gated at the resolved resource's scope — see
94
94
  * the post-`resolveMutable` call in management.ts).
95
+ * - `delete` — user tier only at the facade (config.scope); handleDelete
96
+ * re-gates on the RESOLVED resource tier (management.ts)
97
+ * because `resolveMutable` pools user+project when no scope
98
+ * is given (w5a: the delete sibling of the update gate).
99
+ * - `cleanup` — top-level `scope: "user"` only (handleCleanup routes on
100
+ * that carrier); runId/project cleanup stay project-tier.
101
+ * - `init` — unless the resolved config scope is project/none
102
+ * (config.configScope/config.scope carriers, mirroring
103
+ * dispatch/manage.ts); the default is `global`, which
104
+ * writes `~/.pi/agent/pi-crew.json` (overwrite:true
105
+ * replaces a customized config).
95
106
  * - `workflow-` — ALWAYS (fix-wave 6, R11-S1): .dwf.ts scripts are
96
107
  * `create`/ arbitrary code auto-loaded by every future session —
97
108
  * `save`/ project tier included (`.crew/workflows/` is discovered
@@ -107,8 +118,13 @@ export function shouldBlockDestructiveTeamAction(action: string | undefined, inp
107
118
  * carriers cannot smuggle a user-tier write past the gate.
108
119
  *
109
120
  * Reads (config/status/list, settings list/get, schedule `scheduled`) are
110
- * never gated. `dryRun` previews that write nothing pass through, mirroring
111
- * destructive-gate.ts's dryRun convention.
121
+ * never gated. `dryRun` carriers do NOT exempt mutating actions from this
122
+ * gate (w5a, rootGateDryRunPreviewExemptsMutatingHandlers): handleStart
123
+ * (goal.ts) has no dryRun branch at all and handleCreate (management.ts)
124
+ * honors only top-level `params.dryRun`, so a config-carrier "preview" of
125
+ * `goal start` / `create` would mutate for real. Previews therefore traverse
126
+ * the SAME matrix as execution — the schedule precedent ("previews of actions
127
+ * without a dryRun contract are gated").
112
128
  */
113
129
 
114
130
  /** Typed error so callers/tests can distinguish the gate from handler errors. */
@@ -187,6 +203,32 @@ export function assertRootOnlyAction(
187
203
  case "update":
188
204
  if (scope === "user") return detail("rewrites a user-tier resource loaded by every future session");
189
205
  return undefined;
206
+ case "delete":
207
+ // w5a (rootGateActionMatrixOmitsDeleteCleanupInit): delete shares
208
+ // update's resource resolution (resolveMutable pools user+project when
209
+ // no scope is given) but previously fell through the switch default —
210
+ // a worker could unlink user-tier agents/teams/workflows. Gate the
211
+ // config-carrier user scope HERE (pre-filter, like update) and the
212
+ // RESOLVED resource tier in handleDelete (management.ts), where the
213
+ // matched file's tier is finally known.
214
+ if (scope === "user") return detail("deletes a user-tier resource loaded by every future session");
215
+ return undefined;
216
+ case "cleanup":
217
+ // Only the scope=user branch (handleUserCleanup) destroys user-tier
218
+ // state: the whole userCrewRoot() run-history/artifacts tree plus the
219
+ // global config with force:true. runId cleanup and project cleanup stay
220
+ // project-tier and worker-reachable (Wave-3 policy).
221
+ if (scope === "user") return detail("removes the user-tier pi-crew state directory and global config");
222
+ return undefined;
223
+ case "init":
224
+ // configScope defaults to 'global' (dispatch/manage.ts) -> writes
225
+ // ~/.pi/agent/pi-crew.json; overwrite:true replaces a customized config.
226
+ // The facade resolves init's config scope from its own carriers
227
+ // (config.configScope OR config.scope, mirroring the dispatch union) —
228
+ // global (the default) is user-tier and gated; project/none stay
229
+ // worker-reachable.
230
+ if (scope !== "project") return detail("writes/replaces the global pi-crew config (all future sessions)");
231
+ return undefined;
190
232
  case "workflow-create":
191
233
  case "workflow-save":
192
234
  // R11-S1 (fix-wave 6 M1-3): a .dwf.ts is arbitrary code auto-loaded by
@@ -248,6 +290,24 @@ function resolveScopeParam(params: RootGateParamsLike, action?: string): RootGat
248
290
  return action === "import" || action === "export" ? "project" : "user";
249
291
  }
250
292
 
293
+ /**
294
+ * w5a (rootGateActionMatrixOmitsDeleteCleanupInit): `init` resolves its config
295
+ * scope from config.configScope OR config.scope with UNION semantics
296
+ * (dispatch/manage.ts: either carrier "project" → project; either carrier
297
+ * "none" → none; anything else, including an absent carrier, → "global").
298
+ * Only the "global" outcome writes the user tier (~/.pi/agent/pi-crew.json),
299
+ * so project AND none map to the worker-reachable bucket here. Note
300
+ * `configScope` must NOT participate in the shared {@link resolveScopeParam}:
301
+ * create/settings/config ignore it and default to user, so a configScope:
302
+ * 'project' fallback there would OPEN the gate on a user-tier write.
303
+ */
304
+ function resolveInitConfigScope(params: RootGateParamsLike): RootGateScope {
305
+ const cfg = configRecord(params.config);
306
+ const projectScope = cfg.configScope === "project" || cfg.scope === "project";
307
+ const noneScope = cfg.configScope === "none" || cfg.scope === "none";
308
+ return projectScope || noneScope ? "project" : "user";
309
+ }
310
+
251
311
  /**
252
312
  * Facade resolver: derive (subcommand, scope) from raw team-tool params for
253
313
  * the gated actions and decide whether a NON-ROOT session must be blocked.
@@ -255,7 +315,11 @@ function resolveScopeParam(params: RootGateParamsLike, action?: string): RootGat
255
315
  * {@link resolveScopeParam}); top-level `scope` never influences the gate.
256
316
  * `update` is special: its effective write scope is the matched resource's
257
317
  * scope, unknown until `resolveMutable` — the facade blocks explicit
258
- * `scope:"user"` and management.ts gates the resolved case.
318
+ * `scope:"user"` and management.ts gates the resolved case. `delete` mirrors
319
+ * that split (facade pre-filter + handleDelete resolved-tier gate).
320
+ * `cleanup` routes on TOP-LEVEL `params.scope` (lifecycle-actions.ts), and
321
+ * `init` on config.configScope/config.scope (dispatch/manage.ts) — both
322
+ * carriers are read here so the gate matches each handler exactly.
259
323
  *
260
324
  * @returns block reason string, or `undefined` to allow.
261
325
  */
@@ -267,16 +331,34 @@ export function rootGateBlockReason(params: RootGateParamsLike, env: NodeJS.Proc
267
331
  (typeof params.subAction === "string" ? params.subAction : undefined) ??
268
332
  (typeof cfg.subAction === "string" ? cfg.subAction : undefined);
269
333
  const subcommand = typeof rawSub === "string" ? rawSub.toLowerCase() : undefined;
270
- const scope = resolveScopeParam(params, action);
334
+ // w5a: cleanup reads the TOP-LEVEL scope carrier (handleCleanup routes on
335
+ // params.scope; config.scope is a carrier key it ignores) and init reads
336
+ // config.configScope/config.scope (dispatch/manage.ts). Everything else
337
+ // keeps the R11-F1 canonical config.scope resolver.
338
+ const scope =
339
+ action === "cleanup"
340
+ ? params.scope === "user"
341
+ ? "user"
342
+ : "project"
343
+ : action === "init"
344
+ ? resolveInitConfigScope(params)
345
+ : resolveScopeParam(params, action);
271
346
  const force = params.force === true || cfg.force === true;
272
347
 
273
- // Reads / previews never gated (destructive-gate dryRun convention).
274
- const isPreview = params.dryRun === true || cfg.dryRun === true;
348
+ // w5a (rootGateDryRunPreviewExemptsMutatingHandlers): dryRun carriers do
349
+ // NOT exempt mutating actions here. handleStart (goal.ts) has NO dryRun
350
+ // branch — it persists state and spawns the detached runner regardless —
351
+ // and handleCreate (management.ts) honors only top-level params.dryRun, so
352
+ // a config-carrier "preview" wrote the user tier for real. Previews of
353
+ // mutating actions traverse the SAME matrix as execution (schedule
354
+ // precedent); unrecognized carrier shapes therefore fail closed.
275
355
 
276
356
  // goal: gate `start` and any force:true op; status/pause/read-only stay open.
277
357
  if (action === "goal") {
278
358
  if (force) return assertRootOnlyAction("goal", "force", scope, env)?.message;
279
- if (subcommand === "start" && !isPreview) return assertRootOnlyAction("goal", "start", scope, env)?.message;
359
+ // Gate `start` unconditionally (schedule precedent: previews of actions
360
+ // without a dryRun contract are gated).
361
+ if (subcommand === "start") return assertRootOnlyAction("goal", "start", scope, env)?.message;
280
362
  return undefined;
281
363
  }
282
364
  // schedule: every subAction path mutates (add is the default); previews of
@@ -320,7 +402,9 @@ export function rootGateBlockReason(params: RootGateParamsLike, env: NodeJS.Proc
320
402
  // goal force:true (top-level or config) is its own gated token.
321
403
  if (action === "goal" && force) effectiveSub = "force";
322
404
  // create/update/config/settings/autonomy below share scope gating.
323
- if (isPreview && action === "create") return undefined;
405
+ // create: NO preview exemption — handleCreate honors only top-level
406
+ // params.dryRun (management.ts), so a config-carrier "preview" writes the
407
+ // user tier for real (w5a).
324
408
  const reason = assertRootOnlyAction(action, effectiveSub, scope, env);
325
409
  return reason?.message;
326
410
  }
@@ -4,6 +4,7 @@ import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
4
4
  import { readEventsCursor } from "../../state/event-log/event-log.ts";
5
5
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
6
6
  import { aggregateUsage, formatCostReport, formatUsage } from "../../state/usage.ts";
7
+ import { sanitizeUntrustedLine } from "../../utils/visual.ts";
7
8
  import { locateRunCwd } from "../team-tool.ts";
8
9
  import type { PiTeamsToolResult } from "../tool-result.ts";
9
10
  import { result, type TeamContext } from "./context.ts";
@@ -36,7 +37,9 @@ export function handleEvents(params: TeamToolParamsValue, ctx: TeamContext): PiT
36
37
  ...(events.length
37
38
  ? events.map(
38
39
  (event) =>
39
- `${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${event.message}` : ""}${event.data ? ` ${JSON.stringify(event.data)}` : ""}`,
40
+ // w7a-render: event.message can embed child-stderr excerpts on worker
41
+ // failure — sanitize before it becomes tool-result content rendered by the team card.
42
+ `${event.time} ${event.type}${event.taskId ? ` ${event.taskId}` : ""}${event.message ? `: ${sanitizeUntrustedLine(event.message)}` : ""}${event.data ? ` ${JSON.stringify(event.data)}` : ""}`,
40
43
  )
41
44
  : ["(none)"]),
42
45
  ];
@@ -102,7 +105,10 @@ export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): Pi
102
105
  "",
103
106
  "Tasks:",
104
107
  ...loaded.tasks.map(
105
- (task) => `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.error ? ` - ${task.error}` : ""}`,
108
+ // w7a-render: task.error can embed child-stderr excerpts on worker failure —
109
+ // sanitize before it becomes tool-result content rendered by the team card.
110
+ (task) =>
111
+ `- ${task.id}: ${task.status} (${task.role} -> ${task.agent})${task.error ? ` - ${sanitizeUntrustedLine(task.error)}` : ""}`,
106
112
  ),
107
113
  ];
108
114
  return result(lines.join("\n"), {
@@ -16,7 +16,7 @@ import { cleanupRunWorktrees } from "../../worktree/cleanup.ts";
16
16
  import { listImportedRuns } from "../import-index.ts";
17
17
  import { runCompareBundle } from "../run-compare.ts";
18
18
  import { exportRunBundle } from "../run-export.ts";
19
- import { importRunBundle } from "../run-import.ts";
19
+ import { importRunBundle, sanitizeImportedText } from "../run-import.ts";
20
20
  import { pruneFinishedRuns } from "../run-maintenance.ts";
21
21
  import { locateRunCwd } from "../team-tool.ts";
22
22
  import type { PiTeamsToolResult } from "../tool-result.ts";
@@ -56,12 +56,19 @@ export function handleWorktrees(params: TeamToolParamsValue, ctx: TeamContext):
56
56
 
57
57
  export function handleImports(_params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
58
58
  const imports = listImportedRuns(ctx.cwd);
59
+ // W7B (imported-manifest-goal-raw-render): bundle-authored fields (status,
60
+ // team, workflow, goal) are untrusted repo/plant content re-read from disk
61
+ // on every listing (readEntry validates types only) — render through the
62
+ // sanitizeImportedText seam so forged multi-line entries, injected
63
+ // directives, ANSI escapes and fake fences cannot enter the parent-LLM
64
+ // tool-result context. runId is charset-guarded (isSafePathId); the Bundle
65
+ // / Summary paths are root-joined from that guarded id.
59
66
  const lines = [
60
67
  "Imported pi-crew runs:",
61
68
  ...(imports.length
62
69
  ? imports.map(
63
70
  (entry) =>
64
- `- ${entry.runId} (${entry.scope})${entry.status ? ` [${entry.status}]` : ""} ${entry.team ?? "unknown"}/${entry.workflow ?? "none"}: ${entry.goal ?? ""}\n Bundle: ${entry.bundlePath}\n Summary: ${entry.summaryPath}`,
71
+ `- ${entry.runId} (${entry.scope})${entry.status ? ` [${sanitizeImportedText(entry.status, 24)}]` : ""} ${sanitizeImportedText(entry.team ?? "unknown", 64)}/${sanitizeImportedText(entry.workflow ?? "none", 64)}: ${sanitizeImportedText(entry.goal)}\n Bundle: ${entry.bundlePath}\n Summary: ${entry.summaryPath}`,
65
72
  )
66
73
  : ["- (none)"]),
67
74
  ];