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.
- package/CHANGELOG.md +116 -0
- package/dist/index.mjs +551 -544
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +2 -0
- package/src/agents/agent-config.ts +58 -1
- package/src/config/config-validation.ts +18 -0
- package/src/config/env-allowlist-registry.ts +5 -1
- package/src/config/env-vars.ts +5 -1
- package/src/config/sanitize-project-config.ts +55 -0
- package/src/extension/management.ts +9 -0
- package/src/extension/registration/lifecycle-handlers.ts +28 -0
- package/src/extension/registration/subagent-tools.ts +5 -1
- package/src/extension/run-import.ts +40 -6
- package/src/extension/team-tool/api/agent-control.ts +49 -0
- package/src/extension/team-tool/api/heartbeat.ts +48 -1
- package/src/extension/team-tool/api/mailbox.ts +81 -2
- package/src/extension/team-tool/api/read.ts +22 -0
- package/src/extension/team-tool/api/task-claims.ts +134 -3
- package/src/extension/team-tool/destructive-gate.ts +92 -8
- package/src/extension/team-tool/inspect.ts +8 -2
- package/src/extension/team-tool/lifecycle-actions.ts +9 -2
- package/src/extension/team-tool.ts +128 -27
- package/src/observability/exporters/otlp-exporter.ts +69 -19
- package/src/prompt/message-tool.ts +19 -8
- package/src/prompt/prompt-runtime.ts +160 -51
- package/src/runtime/broker/crew-broker-child.ts +55 -17
- package/src/runtime/broker/crew-broker-tokens.ts +24 -13
- package/src/runtime/broker/crew-broker.ts +12 -6
- package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
- package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
- package/src/runtime/child-pi/child-pi-steering.ts +8 -1
- package/src/runtime/child-pi/child-pi.ts +2 -1
- package/src/runtime/child-pi/inference-broker.ts +41 -7
- package/src/runtime/child-pi/pi-version.ts +31 -3
- package/src/runtime/child-pi/sandbox.ts +38 -1
- package/src/runtime/dwf-state-store.ts +193 -7
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
- package/src/runtime/live-session/live-session-runtime.ts +78 -3
- package/src/runtime/mcp-proxy.ts +65 -6
- package/src/runtime/model/pi-args.ts +16 -15
- package/src/runtime/plan-replan.ts +4 -1
- package/src/runtime/task-runner/child-executor.ts +11 -5
- package/src/runtime/task-runner/hybrid-tier.ts +17 -0
- package/src/runtime/task-runner/pre-execution.ts +13 -6
- package/src/runtime/task-runner.ts +14 -0
- package/src/runtime/team-runner.ts +26 -0
- package/src/runtime/verification/verification-worktree.ts +46 -29
- package/src/runtime/verification/verify-gate-memo.ts +211 -32
- package/src/runtime/verification/verify-gate.ts +266 -14
- package/src/runtime/workspace-lock.ts +95 -21
- package/src/schema/config-schema.ts +12 -2
- package/src/state/coordination/locks.ts +86 -19
- package/src/state/coordination/mailbox.ts +263 -22
- package/src/state/stores/manifest-io.ts +206 -7
- package/src/state/stores/state-store.ts +44 -15
- package/src/state/types.ts +9 -0
- package/src/ui/inline-panel/agent-transcript.ts +151 -11
- package/src/ui/settings-overlay.ts +8 -3
- package/src/ui/theme-discovery.ts +14 -2
- package/src/ui/tool-renderers/index.ts +26 -6
- package/src/utils/safe-terminal-text.ts +60 -0
- package/src/worktree/git-hardening.ts +78 -1
- 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: {
|
|
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: {
|
|
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
|
|
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: {
|
|
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: {
|
|
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`
|
|
111
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
];
|