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