pi-crew 0.9.18 → 0.9.20

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 CHANGED
@@ -1,5 +1,97 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.20] — security hardening: RPC HMAC auth + per-task API key scoping + safe-bash whitelist (2026-07-06)
4
+
5
+ Three defense-in-depth security upgrades distilled from cross-source research (52+ repos) and the H-1/H-2/H-6 audit findings. All three are additive (no breaking changes); two are opt-in via env vars, one is default-on.
6
+
7
+ ### Highlights
8
+
9
+ - **RPC HMAC authentication (opt-in).** All cross-extension RPC channels (`ping`/`run`/`status`/`live-control` at extension layer; `ping`/`spawn`/`stop` at runtime layer) now support HMAC-SHA256 origin signing. When `PI_CREW_RPC_SECRET` is set, every request must carry a valid signature with timestamp + nonce (anti-replay) and channel binding (cross-channel replay guard). Timing-safe comparison prevents timing attacks. Unset = backward-compatible passthrough. Closes the H-2 authorization-bypass finding where any co-installed extension could spoof `source='pi-crew'` to spawn/kill subagents. New module `src/extension/rpc-hmac.ts`; 24 tests in `test/unit/rpc-hmac-auth.test.ts`.
10
+ - **Per-task API key scoping (default-on).** Child workers previously inherited ALL model provider API keys via a broad allowlist. `buildChildPiSpawnOptions()` now takes an optional `model` param and calls `buildScopedAllowList()` to inject only the provider keys needed for the assigned model. When no model is given, only `BASE_ALLOWLIST` system vars pass through (zero provider keys leak). Reduces blast radius: a compromised child only gets keys for its model. Helpers `providerEnvKeys()` + `buildScopedAllowList()` in `src/utils/env-filter.ts`; per-task wiring in `src/runtime/child-pi.ts`; tests in `test/unit/api-key-scoping.test.ts`.
11
+ - **Safe-bash whitelist mode (opt-in).** A deny-by-default whitelist as an opt-in alternative to the legacy blacklist `isDangerous()`. Enabled via `PI_CREW_SAFE_BASH_MODE=whitelist`. Only 16 read-only commands allowed (`ls cat head tail wc grep find echo pwd date whoami uname df du file stat`). Shell metacharacter regex blocks chaining/substitution before the first-token check (`ls; rm file` cannot smuggle `rm`); unmatched quotes are rejected as malformed input. Legacy blacklist path unchanged when not enabled. `src/tools/safe-bash.ts`; 21 tests in `test/unit/safe-bash-whitelist.test.ts`.
12
+
13
+ ### Verification
14
+
15
+ | Gate | Result |
16
+ |---|---|
17
+ | Affected unit tests (20 files) | ✅ 187/187 pass |
18
+ | safe-bash (blacklist + whitelist + ANSI) | ✅ 51/51 |
19
+ | env-filter + API key scoping | ✅ 26/26 |
20
+ | cross-extension-rpc + HMAC | ✅ 48/48 |
21
+ | child-pi (hardening/exit/redaction/compaction) | ✅ 35/35 |
22
+ | security (hardening/artifact/cwd/import/output) | ✅ 27/27 |
23
+ | Live E2E (research workflow) | ✅ 3/3 tasks, end-to-end |
24
+ | Extension load | ✅ |
25
+
26
+ ### Files
27
+
28
+ - NEW: `src/extension/rpc-hmac.ts`, `test/unit/rpc-hmac-auth.test.ts`, `test/unit/api-key-scoping.test.ts`, `test/unit/safe-bash-whitelist.test.ts`
29
+ - MOD: `src/extension/cross-extension-rpc.ts`, `src/runtime/cross-extension-rpc.ts`, `src/utils/env-filter.ts`, `src/runtime/child-pi.ts`, `src/tools/safe-bash.ts`, `src/tools/safe-bash-extension.ts`, `test/unit/env-filter.test.ts`, `test/unit/security-hardening.test.ts`
30
+
31
+ ## [v0.9.19] — plan-execute workflow + main-session→planner analysis handoff (2026-07-03)
32
+
33
+ New builtin workflow for the common "I already analyzed this — just plan + execute + verify it" case, plus a generic `analysis`/`analysisPath` channel on `team action='run'` for handing caller-session context to planner child workers.
34
+
35
+ ### Highlights
36
+
37
+ - **`plan-execute` builtin workflow.** 3-step sequential (plan → execute → verify), no explore step. Designed for callers who have already done the analysis and want the planner to build directly on it. The `plan` step declares `reads: analysis.md` so the caller's pre-analysis is injected via the standard sharedReads dependency-context pipeline. Workflow count: 8 → 9 (`test/unit/discovery.test.ts` updated).
38
+ - **`analysis` / `analysisPath` channel on `team action='run'`.** Two new optional params (mutually exclusive):
39
+ - `analysis` (string, ≤100 000 chars) — inline pre-analysis from the calling session
40
+ - `analysisPath` (string, file path within project) — pre-analysis loaded from a markdown file
41
+ - Both go through `sanitizeTaskText()` (SEC-007 prompt-injection stripper from `buildTaskPacket`) before being injected, so the analysis can never smuggle a directive past the planner. The text is persisted to `artifacts/{runId}/shared/analysis.md` as an audit trail. Mutual exclusivity, path containment (`resolveContainedPath` with null-byte + symlink realpath), and file-not-found / 100 KB cap are all fail-fast, checked BEFORE `createRunManifest` so validation errors never leave orphan run state. Goal-wrapped runs emit a clear `console.warn` if `analysis` is set but ignored (chain dispatch + goal-wrap don't honor v1).
42
+ - **`reads` injection is correctly step-scoped.** Verified live: only the `plan` step (which declares `reads: analysis.md`) receives the analysis content; `execute` and `verify` prompts contain zero unique analysis headings even though the goal text references the analysis file path. This is the intended behavior — the filter limits *dependency-injected* content, not the goal itself.
43
+
44
+ ### Cross-platform fix (was blocking Windows CI)
45
+
46
+ `run-analysis.test.ts` failed on macOS + Windows because `writeArtifact` stores canonicalized paths via `resolveInside`, while the test compared against a path built from raw `mkdtempSync` cwd. Two fixes:
47
+ 1. `fs.realpathSync` the test cwd in `makeRunCwd` — closes the macOS symlink (`/var` → `/private/var`) case.
48
+ 2. Switched the `manifest.artifacts[]` lookup to a normalized suffix compare (`a.path.replace(/\\/g, "/").endsWith("shared/analysis.md")`) — closes the Windows drive-letter-case + separator case. Robust against both realpath drift and Windows path normalization.
49
+
50
+ ### Defense-in-depth
51
+
52
+ - `analysisPath` file size is `statSync`-checked against the same 100 KB cap as the inline channel (prevents prompt-size blowup via the file route).
53
+ - `sanitizeTaskText` applied to both inline AND file content before any persistence or injection.
54
+ - Path traversal on `analysisPath` is rejected with the same `resolveContainedPath` machinery used by other path-taking params (symlink-canonicalized + null-byte guarded).
55
+
56
+ ### Verification
57
+
58
+ | Gate | Result |
59
+ |---|---|
60
+ | TSC | ✅ |
61
+ | Lint | ✅ |
62
+ | format:check | ✅ |
63
+ | check:conflict-markers | ✅ |
64
+ | check:lazy-imports | ✅ |
65
+ | check:bundle-staleness | ✅ (dist rebuilt at 3011.0 KB) |
66
+ | test:unit (run-analysis) | ✅ 9/9 (with --test-timeout=120000) |
67
+ | test:unit (plan-execute-workflow) | ✅ 3/3 |
68
+ | test:unit (discovery, +1 workflow) | ✅ updated assert 8→9 |
69
+ | Live E2E plan-execute + inline analysis | ✅ 3/3 tasks, consistency=1 |
70
+ | Live E2E plan-execute + analysisPath (file) | ✅ planner + executor passed; verifier hit pre-existing child-Pi hang (unrelated) |
71
+ | CI Ubuntu / Node 22 | ✅ |
72
+ | CI macOS / Node 22 | ✅ |
73
+ | CI Windows / Node 22 | ✅ |
74
+ | CI fallow audit | ✅ |
75
+
76
+ ### Changed
77
+
78
+ - `workflows/plan-execute.workflow.md` — new builtin workflow (30 lines).
79
+ - `src/schema/team-tool-schema.ts:264-279` — new `analysis` + `analysisPath` params on `TeamToolParams` + matching interface fields.
80
+ - `src/extension/team-tool/run.ts` — new `resolveAnalysisText()` helper (fail-fast validation + sanitize); analysis artifact write before `atomicWriteJson` of updated manifest; `reads: ["analysis.md"]` injected into direct-agent synthetic workflow when analysis is set; goal-wrap path emits a warning when analysis is provided but ignored.
81
+ - `test/unit/discovery.test.ts:17` — workflows count 8 → 9.
82
+ - `test/unit/plan-execute-workflow.test.ts` — new (46 lines, 3 tests).
83
+ - `test/unit/run-analysis.test.ts` — new (242 lines, 9 tests including size cap, path traversal, mutual exclusion, file-missing).
84
+
85
+ ### Migration notes
86
+
87
+ - Existing `team` tool callers are unaffected — both `analysis` and `analysisPath` are optional. Workflow count is `discoverWorkflows`-reflected, so `team action='list', resource='workflow'` now shows 9 builtin entries instead of 8.
88
+ - No new dependencies. All new code uses existing `sanitizeTaskText`, `resolveContainedPath`, and `writeArtifact` machinery.
89
+ - `plan-execute` joins the builtin workflow family alongside `default`, `fast-fix`, `research`, `review`, `implementation`, `pipeline`, `parallel-research`, and `chain`.
90
+
91
+ ### Known limitation (out of scope for v0.9.19)
92
+
93
+ - Goal-wrapped runs and chain dispatch ignore the `analysis` param in v1 (with a `console.warn`). If callers need analysis in those modes, file an issue and we can plumb it through `goal.ts` / `chain-dispatch.ts` in a follow-up.
94
+
3
95
  ## [v0.9.18] — perf fix bundle-mode spawn + config cache (2026-07-02)
4
96
 
5
97
  Five commits addressing items from the v0.9.17 performance review (`docs/perf/performance-review-2026-07.md`):
@@ -12603,7 +12603,7 @@
12603
12603
  "format": "esm"
12604
12604
  },
12605
12605
  "src/schema/team-tool-schema.ts": {
12606
- "bytes": 12557,
12606
+ "bytes": 13646,
12607
12607
  "imports": [
12608
12608
  {
12609
12609
  "path": "node_modules/@sinclair/typebox/build/esm/index.mjs",
@@ -15768,7 +15768,7 @@
15768
15768
  "format": "esm"
15769
15769
  },
15770
15770
  "src/extension/team-tool/run.ts": {
15771
- "bytes": 39160,
15771
+ "bytes": 42914,
15772
15772
  "imports": [
15773
15773
  {
15774
15774
  "path": "src/agents/discover-agents.ts",
@@ -15785,6 +15785,11 @@
15785
15785
  "kind": "import-statement",
15786
15786
  "original": "../../runtime/pipeline-runner.ts"
15787
15787
  },
15788
+ {
15789
+ "path": "src/runtime/task-packet.ts",
15790
+ "kind": "import-statement",
15791
+ "original": "../../runtime/task-packet.ts"
15792
+ },
15788
15793
  {
15789
15794
  "path": "src/state/active-run-registry.ts",
15790
15795
  "kind": "import-statement",
@@ -18161,7 +18166,7 @@
18161
18166
  "imports": [],
18162
18167
  "exports": [],
18163
18168
  "inputs": {},
18164
- "bytes": 6421066
18169
+ "bytes": 6427881
18165
18170
  },
18166
18171
  "dist/index.mjs": {
18167
18172
  "imports": [
@@ -21202,7 +21207,7 @@
21202
21207
  "bytesInOutput": 6424
21203
21208
  },
21204
21209
  "src/schema/team-tool-schema.ts": {
21205
- "bytesInOutput": 11030
21210
+ "bytesInOutput": 11702
21206
21211
  },
21207
21212
  "src/extension/team-tool/doctor.ts": {
21208
21213
  "bytesInOutput": 12349
@@ -21559,7 +21564,7 @@
21559
21564
  "bytesInOutput": 6259
21560
21565
  },
21561
21566
  "src/extension/team-tool/run.ts": {
21562
- "bytesInOutput": 31284
21567
+ "bytesInOutput": 33569
21563
21568
  },
21564
21569
  "src/extension/team-tool.ts": {
21565
21570
  "bytesInOutput": 35293
@@ -21778,7 +21783,7 @@
21778
21783
  "bytesInOutput": 81
21779
21784
  }
21780
21785
  },
21781
- "bytes": 3080272
21786
+ "bytes": 3083229
21782
21787
  }
21783
21788
  }
21784
21789
  }
package/dist/index.mjs CHANGED
@@ -46604,6 +46604,17 @@ var init_team_tool_schema = __esm({
46604
46604
  // "description-only schema" strict-provider check.
46605
46605
  Type.Any()
46606
46606
  ),
46607
+ analysis: Type.Optional(
46608
+ Type.String({
46609
+ maxLength: 1e5,
46610
+ description: "Inline analysis/context notes from the calling session. Persisted to artifacts/{runId}/shared/analysis.md (audit trail) and auto-injected into any workflow step declaring reads: analysis.md (e.g. builtin 'plan-execute'). Mutually exclusive with analysisPath. Ignored by goal-wrapped runs and chain dispatch in v1."
46611
+ })
46612
+ ),
46613
+ analysisPath: Type.Optional(
46614
+ Type.String({
46615
+ description: "Path to an existing markdown analysis file (resolved within cwd). Copied into shared/analysis.md. Mutually exclusive with analysis."
46616
+ })
46617
+ ),
46607
46618
  focus: Type.Optional(
46608
46619
  Type.String({
46609
46620
  description: "Sub-focus for the doctor action. 'zombies' runs a READ-ONLY scan for orphaned pi-crew sub-agent processes (identified by PI_CREW_KIND=subagent); it never kills and never matches the user's interactive main session."
@@ -69542,6 +69553,48 @@ ${tail}` : ""}`;
69542
69553
  }, 3e3);
69543
69554
  timer.unref();
69544
69555
  }
69556
+ function resolveAnalysisText(params, cwd) {
69557
+ const hasInline = typeof params.analysis === "string" && params.analysis.length > 0;
69558
+ const hasPath = typeof params.analysisPath === "string" && params.analysisPath.length > 0;
69559
+ if (hasInline && hasPath) {
69560
+ return {
69561
+ error: "`analysis` and `analysisPath` are mutually exclusive. Set exactly one.",
69562
+ source: "none"
69563
+ };
69564
+ }
69565
+ if (!hasInline && !hasPath) return { source: "none" };
69566
+ if (hasPath) {
69567
+ let resolved;
69568
+ try {
69569
+ resolved = resolveContainedPath(cwd, params.analysisPath);
69570
+ } catch {
69571
+ return {
69572
+ error: `analysisPath must be within project directory: ${params.analysisPath}`,
69573
+ source: "none"
69574
+ };
69575
+ }
69576
+ if (!fs90.existsSync(resolved)) {
69577
+ return {
69578
+ error: `Analysis file not found: ${resolved}`,
69579
+ source: "none"
69580
+ };
69581
+ }
69582
+ const { size } = fs90.statSync(resolved);
69583
+ if (size > MAX_ANALYSIS_BYTES) {
69584
+ return {
69585
+ error: `Analysis file too large: ${size} bytes (max ${MAX_ANALYSIS_BYTES}). Trim the analysis or pass a summary inline.`,
69586
+ source: "none"
69587
+ };
69588
+ }
69589
+ const raw = fs90.readFileSync(resolved, "utf-8");
69590
+ const sanitized2 = sanitizeTaskText(raw);
69591
+ if (!sanitized2) return { source: "none" };
69592
+ return { text: sanitized2, source: "path" };
69593
+ }
69594
+ const sanitized = sanitizeTaskText(params.analysis);
69595
+ if (!sanitized) return { source: "none" };
69596
+ return { text: sanitized, source: "inline" };
69597
+ }
69545
69598
  async function handleRun(params, ctx) {
69546
69599
  if (params.chain) {
69547
69600
  const { handleChainRun: handleChainRun2 } = await Promise.resolve().then(() => (init_chain_dispatch(), chain_dispatch_exports));
@@ -69625,11 +69678,14 @@ Commit or stash changes before using worktree mode, or use workspaceMode: 'singl
69625
69678
  id: "01_agent",
69626
69679
  role: params.role ?? "agent",
69627
69680
  task: "{goal}",
69628
- model: params.model
69681
+ model: params.model,
69682
+ reads: params.analysis || params.analysisPath ? ["analysis.md"] : void 0
69629
69683
  }
69630
69684
  ]
69631
69685
  } : workflows.find((item) => item.name === workflowName);
69632
69686
  if (!baseWorkflow) return result(`Workflow '${workflowName}' not found.`, { action: "run", status: "error" }, true);
69687
+ const analysisParam = resolveAnalysisText(params, resolvedCtx.cwd);
69688
+ if (analysisParam.error) return result(analysisParam.error, { action: "run", status: "error" }, true);
69633
69689
  const { expandParallelResearchWorkflow: expandParallelResearch } = await Promise.resolve().then(() => (init_parallel_research(), parallel_research_exports));
69634
69690
  const workflow = directAgent ? baseWorkflow : expandParallelResearch(baseWorkflow, resolvedCtx.cwd);
69635
69691
  if (!directAgent) {
@@ -69647,6 +69703,11 @@ Commit or stash changes before using worktree mode, or use workspaceMode: 'singl
69647
69703
  if (!directAgent && workflow.source === "builtin" && isGoalWrapEnabled(resolvedCtx.cwd, workflow.name)) {
69648
69704
  const decision2 = shouldGoalWrap(resolvedCtx.cwd, workflow);
69649
69705
  if (decision2.enabled) {
69706
+ if (analysisParam.text) {
69707
+ console.warn(
69708
+ `[team-tool.run] analysis param is ignored by goal-wrapped run (workflow=${workflow.name}). The analysis artifact will not be written.`
69709
+ );
69710
+ }
69650
69711
  return await startGoalWrappedRun(params, ctx, workflow, goal);
69651
69712
  }
69652
69713
  if (decision2.message) {
@@ -69722,10 +69783,19 @@ Commit or stash changes before using worktree mode, or use workspaceMode: 'singl
69722
69783
  `,
69723
69784
  producer: "team-tool"
69724
69785
  });
69786
+ const analysisArtifacts = analysisParam.text ? [
69787
+ writeArtifact(paths.artifactsRoot, {
69788
+ kind: "prompt",
69789
+ relativePath: "shared/analysis.md",
69790
+ content: `${analysisParam.text}
69791
+ `,
69792
+ producer: "team-tool"
69793
+ })
69794
+ ] : [];
69725
69795
  const updatedManifest = {
69726
69796
  ...manifest,
69727
69797
  ...skillOverride !== void 0 ? { skillOverride } : {},
69728
- artifacts: [goalArtifact],
69798
+ artifacts: [goalArtifact, ...analysisArtifacts],
69729
69799
  summary: "Run manifest created; worker execution is not implemented yet."
69730
69800
  };
69731
69801
  atomicWriteJson(paths.manifestPath, updatedManifest);
@@ -70239,13 +70309,14 @@ ${dwfResult.manifest.summary ?? ""}`,
70239
70309
  executed.manifest.status === "failed"
70240
70310
  );
70241
70311
  }
70242
- var _cachedExecuteTeamRun, crewInitPromise;
70312
+ var _cachedExecuteTeamRun, crewInitPromise, MAX_ANALYSIS_BYTES;
70243
70313
  var init_run = __esm({
70244
70314
  "src/extension/team-tool/run.ts"() {
70245
70315
  "use strict";
70246
70316
  init_discover_agents();
70247
70317
  init_config();
70248
70318
  init_pipeline_runner();
70319
+ init_task_packet();
70249
70320
  init_active_run_registry();
70250
70321
  init_artifact_store();
70251
70322
  init_atomic_write();
@@ -70265,6 +70336,7 @@ var init_run = __esm({
70265
70336
  init_config_patch();
70266
70337
  init_context();
70267
70338
  init_goal_wrap();
70339
+ MAX_ANALYSIS_BYTES = 1e5;
70268
70340
  }
70269
70341
  });
70270
70342