pi-crew 0.11.50 → 0.11.52
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 +143 -0
- package/dist/index.mjs +552 -545
- package/package.json +1 -1
- package/src/agents/agent-config.ts +58 -1
- package/src/config/config-validation.ts +18 -0
- package/src/config/env-allowlist-registry.ts +4 -0
- package/src/config/env-vars.ts +4 -0
- 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/cancel.ts +15 -1
- 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/background-runner.ts +101 -81
- package/src/runtime/broker/crew-broker-child.ts +32 -0
- 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 +17 -0
- package/src/runtime/child-pi/child-pi-steering.ts +8 -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/foreground-control.ts +95 -1
- 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/heartbeat/heartbeat-watcher.ts +32 -11
- 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 +34 -7
- package/src/runtime/task-runner/hybrid-tier.ts +17 -0
- package/src/runtime/task-runner/live-executor.ts +3 -0
- package/src/runtime/task-runner/post-execution.ts +5 -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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { getCrewEnv } from "../config/env-vars.ts";
|
|
3
4
|
import { atomicWriteFile } from "../state/atomic-write.ts";
|
|
4
|
-
import { appendEvent } from "../state/event-log/event-log.ts";
|
|
5
|
+
import { appendEvent, appendEventBuffered } from "../state/event-log/event-log.ts";
|
|
5
6
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
6
7
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
7
8
|
import { sleepSync } from "../utils/sleep.ts";
|
|
@@ -204,3 +205,96 @@ export function writeForegroundInterruptRequest(
|
|
|
204
205
|
releaseLock();
|
|
205
206
|
}
|
|
206
207
|
}
|
|
208
|
+
|
|
209
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
210
|
+
// FIX-1 (live catch, run team_20261030044630_3935473bc4886682): the
|
|
211
|
+
// foreground-control interrupt CONSUMER. The guard used to live ONLY inside
|
|
212
|
+
// background-runner's detached main(); runs executed INLINE in the owner
|
|
213
|
+
// session process (executeBackgroundRun core — also the PI_CREW_TEST_ASYNC_INLINE
|
|
214
|
+
// seam) never polled foreground-control.json, so cross-session cancels sat
|
|
215
|
+
// acknowledged:false forever (two requests in that run) while the worker ran
|
|
216
|
+
// ≥75s post-cancel with fresh heartbeats. Colocated with the PRODUCER
|
|
217
|
+
// (writeForegroundInterruptRequest above) so the control-file contract has
|
|
218
|
+
// one home.
|
|
219
|
+
//
|
|
220
|
+
// Polling/ack semantics carried over byte-identically from the previous
|
|
221
|
+
// inline guard in background-runner.ts: existsSync+readFileSync+JSON.Parse
|
|
222
|
+
// per tick, interval PI_CREW_INTERRUPT_GUARD_INTERVAL_MS (default 1000ms),
|
|
223
|
+
// synchronous atomic ack write, once-per-request gate (defense-in-depth when
|
|
224
|
+
// the ack write fails), appendEventBuffered("async.interrupt_detected"),
|
|
225
|
+
// interval.unref().
|
|
226
|
+
//
|
|
227
|
+
// What an interrupt DOES is host-scoped via onInterrupt:
|
|
228
|
+
// - detached runner (background-runner.startInterruptGuard): process-wide
|
|
229
|
+
// sweep — terminateActiveChildPiProcesses + stopParentGuard + exitCode 130.
|
|
230
|
+
// - inline core (executeBackgroundRun with no host signal): abort ONLY this
|
|
231
|
+
// run's controller. NEVER sweep: the owner session process may host
|
|
232
|
+
// sibling runs whose child-pi processes would be murdered by a global
|
|
233
|
+
// terminateActiveChildPiProcesses() call.
|
|
234
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
235
|
+
|
|
236
|
+
export interface RunInterruptGuardOptions {
|
|
237
|
+
manifest: { runId: string; stateRoot: string; eventsPath: string };
|
|
238
|
+
/**
|
|
239
|
+
* Host-scoped interrupt action. Runs ONCE per unacknowledged request,
|
|
240
|
+
* after the synchronous ack write. Keep it synchronous: this is a
|
|
241
|
+
* setInterval polling callback — it cannot await.
|
|
242
|
+
*/
|
|
243
|
+
onInterrupt: () => void;
|
|
244
|
+
/** Message for the async.interrupt_detected event (hosts describe their own follow-up). */
|
|
245
|
+
eventMessage?: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function startRunInterruptGuard(opts: RunInterruptGuardOptions): () => void {
|
|
249
|
+
const controlPath = path.join(opts.manifest.stateRoot, "foreground-control.json");
|
|
250
|
+
// PERF (2026-08-24, carried over verbatim): default 1000ms. Each tick does
|
|
251
|
+
// existsSync + readFileSync + JSON.parse for the whole run, and the guard
|
|
252
|
+
// is best-effort interrupt LATENCY, not a correctness deadline — a late
|
|
253
|
+
// trigger merely delays the SIGTERM (children are SIGKILLed after
|
|
254
|
+
// HARD_KILL_MS=3s regardless) and no state machine aborts on a deadline
|
|
255
|
+
// when the guard fires late. Nothing downstream requires a tighter cadence.
|
|
256
|
+
const interruptGuardInterval = Number(getCrewEnv("PI_CREW_INTERRUPT_GUARD_INTERVAL_MS")) || 1000;
|
|
257
|
+
// RT-4 FIX (carried over verbatim): local gate so the interrupt body runs
|
|
258
|
+
// only once per request. The ack write stops the re-fire; this gate is
|
|
259
|
+
// defense-in-depth if the ack write fails (e.g. transient fs error).
|
|
260
|
+
let interruptHandled = false;
|
|
261
|
+
const interval = setInterval(() => {
|
|
262
|
+
try {
|
|
263
|
+
if (!fs.existsSync(controlPath)) return;
|
|
264
|
+
const parsed = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as {
|
|
265
|
+
requests?: Array<{ type: string; acknowledged?: boolean }>;
|
|
266
|
+
};
|
|
267
|
+
const last = parsed.requests?.at(-1);
|
|
268
|
+
if (last?.type === "interrupt" && last?.acknowledged !== true) {
|
|
269
|
+
if (interruptHandled) return;
|
|
270
|
+
interruptHandled = true;
|
|
271
|
+
// RT-4 FIX: write acknowledged:true back SYNCHRONOUSLY so the next
|
|
272
|
+
// tick does not re-fire. Sync because a polling callback cannot await.
|
|
273
|
+
try {
|
|
274
|
+
const reqs = parsed.requests ?? [];
|
|
275
|
+
if (reqs.length > 0) {
|
|
276
|
+
reqs[reqs.length - 1].acknowledged = true;
|
|
277
|
+
atomicWriteFile(controlPath, JSON.stringify(parsed, null, 2));
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
/* best-effort ack — interruptHandled gate prevents re-fire */
|
|
281
|
+
}
|
|
282
|
+
appendEventBuffered(opts.manifest.eventsPath, {
|
|
283
|
+
type: "async.interrupt_detected",
|
|
284
|
+
runId: opts.manifest.runId,
|
|
285
|
+
message: opts.eventMessage ?? "Background runner detected foreground interrupt — killing child processes and exiting.",
|
|
286
|
+
}).catch((e) => logInternalError("interrupt-guard.buffered", e, "type=async.interrupt_detected"));
|
|
287
|
+
opts.onInterrupt();
|
|
288
|
+
// Cosmetic (verifier-flagged, live catch follow-up): an ALREADY-ACKED
|
|
289
|
+
// interrupt must not fall through to this branch — it used to warn
|
|
290
|
+
// "Ignoring unknown ... interrupt" every tick until guard stop.
|
|
291
|
+
} else if (last && !(last.type === "interrupt" && last.acknowledged === true)) {
|
|
292
|
+
console.warn(`[background-runner] Ignoring unknown foreground control request: ${last.type}`);
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
/* ignore read/parse errors */
|
|
296
|
+
}
|
|
297
|
+
}, interruptGuardInterval);
|
|
298
|
+
interval.unref();
|
|
299
|
+
return () => clearInterval(interval);
|
|
300
|
+
}
|
|
@@ -26,7 +26,8 @@ import { randomBytes } from "node:crypto";
|
|
|
26
26
|
import type { TSchema } from "@sinclair/typebox";
|
|
27
27
|
import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
28
28
|
import { allAgents, discoverAgents } from "../../agents/discover-agents.ts";
|
|
29
|
-
import {
|
|
29
|
+
import { renderInboxMessage } from "../../prompt/prompt-runtime.ts";
|
|
30
|
+
import { appendMailboxMessage, type MailboxMessage, readMailbox } from "../../state/coordination/mailbox.ts";
|
|
30
31
|
import { appendEvent } from "../../state/event-log/event-log.ts";
|
|
31
32
|
import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
32
33
|
import type { TeamRunManifest } from "../../state/types.ts";
|
|
@@ -383,6 +384,27 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
383
384
|
// `while(true){review; if accept break; retry}` loop replay round-1's
|
|
384
385
|
// verdict forever (stale-verdict hazard, docs/dynamic-workflows.md).
|
|
385
386
|
const hydratedCallIds = new Set<string>(Object.keys(opts.resumedState?.completedAgentCalls ?? {}));
|
|
387
|
+
// W1b binding (security fix gatherreplies-replyto-only-binding): messageId →
|
|
388
|
+
// expected addressee, recorded when ctx.mail() issues each mail. gatherReplies
|
|
389
|
+
// only accepts a reply row when its broker-authenticated sender (m.from)
|
|
390
|
+
// equals the expected addressee of the mail the reply claims (m.replyTo) — a
|
|
391
|
+
// forged replyTo from any other task cannot enter the reply stream.
|
|
392
|
+
const mailedAddressees = new Map<string, string>();
|
|
393
|
+
const unresolvedMailLookups = new Set<string>();
|
|
394
|
+
// Resolve the expected addressee for a mailed id, falling back to the run
|
|
395
|
+
// outbox ONCE per unknown id (resume path: mails sent by a previous process
|
|
396
|
+
// of the same workflow are not in the in-memory map; their outbox rows are
|
|
397
|
+
// host-written — workers cannot append to the outbox lane — so the recorded
|
|
398
|
+
// `to` is a trusted binding source. Inbox rows are never consulted).
|
|
399
|
+
const expectedReplyAddressee = (messageId: string): string | undefined => {
|
|
400
|
+
const known = mailedAddressees.get(messageId);
|
|
401
|
+
if (known !== undefined) return known;
|
|
402
|
+
if (unresolvedMailLookups.has(messageId)) return undefined;
|
|
403
|
+
unresolvedMailLookups.add(messageId);
|
|
404
|
+
const mailed = readMailbox(manifest, "outbox").find((m) => m.id === messageId && m.from === "dynamic-workflow" && m.to);
|
|
405
|
+
if (mailed?.to) mailedAddressees.set(messageId, mailed.to);
|
|
406
|
+
return mailedAddressees.get(messageId);
|
|
407
|
+
};
|
|
386
408
|
// round-14 P1-2: frozen budget surface. The closures read wfState.spent so the
|
|
387
409
|
// object stays live after Object.freeze(ctx). total is a snapshot primitive.
|
|
388
410
|
const budget = Object.freeze({
|
|
@@ -852,18 +874,37 @@ export function makeWorkflowCtx(manifest: TeamRunManifest, opts: MakeWorkflowCtx
|
|
|
852
874
|
replyTo: mailOpts?.replyTo,
|
|
853
875
|
replyDeadline: mailOpts?.replyDeadline,
|
|
854
876
|
});
|
|
877
|
+
// W1b binding: record the expected addressee so gatherReplies can verify
|
|
878
|
+
// that a reply row's authenticated sender (from) is the task this mail
|
|
879
|
+
// was addressed to (replyTo target ownership).
|
|
880
|
+
mailedAddressees.set(msg.id, to);
|
|
855
881
|
return msg.id;
|
|
856
882
|
},
|
|
857
883
|
async gatherReplies(messageIds: string[], deadlineMs: number): Promise<unknown[]> {
|
|
884
|
+
// W1b binding (security fix gatherreplies-replyto-only-binding): a reply
|
|
885
|
+
// row counts ONLY when (a) it targets the run-level parent lane
|
|
886
|
+
// (to === "parent" — the lane this gather polls), (b) its replyTo is one
|
|
887
|
+
// of the awaited ids, and (c) its broker-authenticated sender (from)
|
|
888
|
+
// equals the addressee recorded for that id at mail time. Duplicate rows
|
|
889
|
+
// from one sender no longer satisfy a multi-id gather (distinct
|
|
890
|
+
// (replyTo, from) pairs are counted toward messageIds.length), and every
|
|
891
|
+
// delivered body is fenced through renderInboxMessage (same seam as
|
|
892
|
+
// worker inbox delivery: control-char strip, closing-tag neutralization,
|
|
893
|
+
// 8192-char cap) before it enters workflow variables.
|
|
894
|
+
const collectBoundReplies = (): MailboxMessage[] =>
|
|
895
|
+
readMailbox(manifest, "inbox").filter((m) => {
|
|
896
|
+
if (!m.replyTo || !messageIds.includes(m.replyTo) || m.to !== "parent") return false;
|
|
897
|
+
return expectedReplyAddressee(m.replyTo) === m.from;
|
|
898
|
+
});
|
|
899
|
+
const distinctReplyPairs = (rows: MailboxMessage[]): number => new Set(rows.map((m) => `${m.replyTo}\u0000${m.from}`)).size;
|
|
900
|
+
const fenceReplyRow = (m: MailboxMessage): MailboxMessage => ({ ...m, body: renderInboxMessage(m) });
|
|
858
901
|
const deadline = Date.now() + deadlineMs;
|
|
859
|
-
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
if (
|
|
902
|
+
for (;;) {
|
|
903
|
+
const got = collectBoundReplies();
|
|
904
|
+
if (distinctReplyPairs(got) >= messageIds.length) return got.map(fenceReplyRow);
|
|
905
|
+
if (Date.now() >= deadline || ctxSignal.aborted) return got.map(fenceReplyRow);
|
|
863
906
|
await new Promise((r) => setTimeout(r, 500));
|
|
864
|
-
if (ctxSignal.aborted) return inbox.filter((m) => m.replyTo && messageIds.includes(m.replyTo));
|
|
865
907
|
}
|
|
866
|
-
return readMailbox(manifest, "inbox").filter((m) => m.replyTo && messageIds.includes(m.replyTo));
|
|
867
908
|
},
|
|
868
909
|
renderTemplate(name: string, vars: Record<string, string>): unknown {
|
|
869
910
|
return renderPlanTemplate(name, vars);
|
|
@@ -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
|
-
//
|
|
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
|
|
183
|
-
// PI_CREW_TRUST_PROJECT_DWF=1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -109,22 +109,43 @@ export class HeartbeatWatcher {
|
|
|
109
109
|
activeKeys.add(key);
|
|
110
110
|
this.lastSeen.set(key, now);
|
|
111
111
|
|
|
112
|
-
//
|
|
113
|
-
|
|
112
|
+
// FIX-2 (live catch, run team_20261030044630_3935473bc4886682):
|
|
113
|
+
// attempt-clamp. handleRetry re-queues tasks WITHOUT resetting
|
|
114
|
+
// liveness fields, so a retry attempt inherited attempt-1's stale
|
|
115
|
+
// heartbeat/agentProgress timestamps and this watcher fired
|
|
116
|
+
// heartbeat_dead 3× for a worker that was ALIVE (fresh heartbeats,
|
|
117
|
+
// active tool calls). Evidence predating the CURRENT attempt's
|
|
118
|
+
// task.startedAt is meaningless and must be ignored; startedAt is
|
|
119
|
+
// reset per attempt at dispatch (child-executor attemptStartedAt),
|
|
120
|
+
// so it is the authoritative attempt boundary.
|
|
121
|
+
const startedAtMs = task.startedAt !== undefined ? Date.parse(task.startedAt) : Number.NaN;
|
|
122
|
+
const attemptStartMs = Number.isFinite(startedAtMs) ? startedAtMs : undefined;
|
|
123
|
+
const evidenceUsable = (iso: string | undefined): boolean => {
|
|
124
|
+
if (iso === undefined) return false;
|
|
125
|
+
const ms = Date.parse(iso);
|
|
126
|
+
return Number.isFinite(ms) && (attemptStartMs === undefined || ms >= attemptStartMs);
|
|
127
|
+
};
|
|
128
|
+
// Check heartbeat staleness with lastActivityAt fallback (evidence
|
|
129
|
+
// older than startedAt is inadmissible — see clamp above).
|
|
130
|
+
let elapsed = evidenceUsable(task.heartbeat?.lastSeenAt) ? heartbeatAgeMs(task.heartbeat, now) : Number.POSITIVE_INFINITY;
|
|
114
131
|
// PR #6 partial: use lastActivityAt as fallback when heartbeat is stale
|
|
115
132
|
// If heartbeat is stale but lastActivityAt is fresher, use activity age instead.
|
|
116
133
|
// This prevents false-positive dead detection for live-session tasks during long operations.
|
|
117
|
-
if (task.agentProgress?.lastActivityAt) {
|
|
118
|
-
const
|
|
119
|
-
if
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (activityAge < elapsed) {
|
|
124
|
-
elapsed = activityAge;
|
|
125
|
-
}
|
|
134
|
+
if (evidenceUsable(task.agentProgress?.lastActivityAt)) {
|
|
135
|
+
const activityAge = now - new Date(task.agentProgress!.lastActivityAt!).getTime();
|
|
136
|
+
// Use activity age if it's fresher than heartbeat age
|
|
137
|
+
// (no upper bound - if agent has recent activity, trust it even if old)
|
|
138
|
+
if (activityAge < elapsed) {
|
|
139
|
+
elapsed = activityAge;
|
|
126
140
|
}
|
|
127
141
|
}
|
|
142
|
+
// No usable liveness evidence at all → compute elapsed from the
|
|
143
|
+
// attempt's own start: a task younger than deadMs can never be dead
|
|
144
|
+
// (previously a missing heartbeat meant elapsed=∞ → instant "dead").
|
|
145
|
+
// Tasks without startedAt keep the legacy ∞ (unclassifiable).
|
|
146
|
+
if (!Number.isFinite(elapsed) && attemptStartMs !== undefined) {
|
|
147
|
+
elapsed = Math.max(0, now - attemptStartMs);
|
|
148
|
+
}
|
|
128
149
|
// PID liveness gate: if the worker process is still alive, downgrade
|
|
129
150
|
// "dead" to "stale". This prevents false positives when the LLM spends
|
|
130
151
|
// a long time generating a response (>5 min) without tool calls.
|
|
@@ -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 {
|
|
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
|
-
|
|
364
|
-
|
|
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).
|
package/src/runtime/mcp-proxy.ts
CHANGED
|
@@ -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 (
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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((
|
|
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(","));
|