pi-crew 0.9.29 → 0.9.31

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,53 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.30] — observability hardening + steering injection guards (2026-07-10)
4
+
5
+ Cold-verified review of 12 findings across security, code quality, and explorer passes yielded 11 confirmed bugs (1 NOT-A-BUG). This release ships 9 of those fixes; the remaining 2 are documented as deferred follow-ups.
6
+
7
+ ### Security (HIGH)
8
+ - **Steering content sanitization** (`src/prompt/prompt-runtime.ts`): `sanitizeSteerMessage()` rejects messages > 4096 chars, with > 50 newlines, or containing C0 control characters before injection via `pi.sendMessage({ deliverAs: "steer" })`. Rejection logged via `logInternalError(..., "warn")`. Closes SEC-01 prompt-injection vector.
9
+ - **Steering file path containment** (`src/prompt/prompt-runtime.ts`): `validateSteeringFile()` uses `resolveRealContainedPath` from `src/utils/safe-paths.ts` to verify `PI_CREW_STEERING_FILE` stays inside the session's artifacts root, plus `lstatSync` to reject symlinks at the path itself. Closes F-07 defense-in-depth gap.
10
+
11
+ ### Observability (HIGH)
12
+ - **`logInternalError` severity tiers** (`src/utils/internal-error.ts`): added optional 4th `severity` parameter (`"error" | "warn" | "debug"`). `"error"` and `"warn"` always emit to `console.error` regardless of `PI_TEAMS_DEBUG`; `"debug"` (default) preserves the existing gated behavior. Backward-compatible — all 100+ existing callers default to `"debug"`. Closes F-01 root cause (silent internal-error suppression in production).
13
+ - **Critical callers upgraded** to `severity="error"`:
14
+ - `task-runner/state-helpers.ts` — CAS convergence failure
15
+ - `child-pi.ts` — Windows taskkill zombie (after retry exhaustion)
16
+ - `team-runner.ts` — false-green goal-achievement downgrade
17
+ - `atomic-write.ts` — coalesced flush failure (potential data loss)
18
+ - `crash-recovery.ts` — orphan/PID-dead/reconcile termination failures upgraded to `"warn"`
19
+
20
+ ### Correctness (MED)
21
+ - **CAS retry error message accuracy** (`src/runtime/task-runner/state-helpers.ts`): extracted `MAX_CAS_ATTEMPTS = 100` constant; loop bound and error message now both interpolate the constant. Previously the message said "50 attempts" while the loop ran 100 — misleading post-mortem debug. Closes F-03.
22
+ - **Stale skill-instructions comment removed** (`src/runtime/skill-instructions.ts`): deleted the 6-line `SECURITY NOTE` block that claimed project-level skills were "FOUND FIRST" — superseded by SEC-003 fix which made package skills trusted-first. Closes F-06 (maintainability hazard).
23
+ - **Symlink cache TTL 30s → 10s** (`src/state/atomic-write.ts`): reduced `SYMLINK_SAFE_TTL_MS` and replaced misleading "invalidated explicitly via `invalidateSymlinkSafeCache(dir)`" comment with accurate TTL-only trust-model documentation. `invalidateSymlinkSafeCache()` remains exported but is intentionally unused (the `.crew/` state directory is trusted — single-user, non-shared). Closes F-02.
24
+
25
+ ### Performance (MED — partial)
26
+ - **`appendEvent` → `appendEventAsync` migration** (`src/runtime/async-runner.ts`): migrated the single hot-path call site in `spawnBackgroundTeamRun` from sync `appendEvent` (which routes through `withEventLogLockSync` → `sleepSync(10)`, blocking the event loop on contention) to async `appendEventAsync`. Remaining 23 call sites in `goal-loop-runner.ts` (12) and `team-tool/api.ts` (11) deferred to follow-up — each requires per-site ordering analysis.
27
+
28
+ ### Reverted
29
+ - **FIX-07 withRunLockSync retry wrapper** (`src/state/locks.ts`): implementation reverted. The retry wrapper extended failure time from instant to 60s (`staleMs × 2`) on hard-fail lock contention, breaking the existing `api-locks.test.ts:37` expectation that mutating API operations fail fast when the lock file holds stale content. Original fast-fail behavior is correct: a fresh-alive lock is a hard-fail signal that the caller (not the lock library) should decide whether to retry.
30
+
31
+ ### Deferred
32
+ - **FIX-09 verification-gates `sh -c` → `execFile` migration**: existing newline + `DANGEROUS_SHELL_PATTERNS` validation covers the known injection vectors. Migration deferred as defense-in-depth — commands are user-supplied, not worker-injected.
33
+
34
+ ### Tests added
35
+ - `test/unit/internal-error.test.ts` — severity tier coverage (4 tests).
36
+ - `test/unit/prompt-runtime-steering.test.ts` — FIX-02 + FIX-03 validation (6 tests covering length, newline, control char, path containment, symlink, valid-content passthrough).
37
+ - `test/unit/state-helpers.test.ts` — FIX-01b + FIX-05 validation (2 tests).
38
+
39
+ ### Verification
40
+ - 2995 unit tests passing, 0 failures (via `node scripts/test-runner.mjs`).
41
+ - End-to-end smoke test: `fast-fix` and `research` workflows completed successfully against the fixed runtime, all state-persistence paths working.
42
+
43
+ ---
44
+
45
+ ## [0.9.29] — version bump only (2026-07-10)
46
+
47
+ > Maintenance release — no source changes. Version bumped to 0.9.29 prior to v0.9.30 development. CHANGELOG entry retroactively added for completeness.
48
+
49
+ ---
50
+
3
51
  ## [0.9.28] — maxTokens cap + real-time steering + provider-per-model (2026-07-09)
4
52
 
5
53
  ### Features
@@ -6087,7 +6087,7 @@
6087
6087
  "format": "esm"
6088
6088
  },
6089
6089
  "src/utils/internal-error.ts": {
6090
- "bytes": 384,
6090
+ "bytes": 554,
6091
6091
  "imports": [],
6092
6092
  "format": "esm"
6093
6093
  },
@@ -6129,7 +6129,7 @@
6129
6129
  "format": "esm"
6130
6130
  },
6131
6131
  "src/state/atomic-write.ts": {
6132
- "bytes": 34938,
6132
+ "bytes": 34941,
6133
6133
  "imports": [
6134
6134
  {
6135
6135
  "path": "node:crypto",
@@ -6814,7 +6814,7 @@
6814
6814
  "format": "esm"
6815
6815
  },
6816
6816
  "src/runtime/crash-recovery.ts": {
6817
- "bytes": 22623,
6817
+ "bytes": 22655,
6818
6818
  "imports": [
6819
6819
  {
6820
6820
  "path": "node:fs",
@@ -7356,7 +7356,7 @@
7356
7356
  "format": "esm"
7357
7357
  },
7358
7358
  "src/runtime/child-pi.ts": {
7359
- "bytes": 66015,
7359
+ "bytes": 66030,
7360
7360
  "imports": [
7361
7361
  {
7362
7362
  "path": "node:child_process",
@@ -12301,7 +12301,7 @@
12301
12301
  "format": "esm"
12302
12302
  },
12303
12303
  "src/runtime/skill-instructions.ts": {
12304
- "bytes": 13808,
12304
+ "bytes": 13449,
12305
12305
  "imports": [
12306
12306
  {
12307
12307
  "path": "node:fs",
@@ -12839,7 +12839,7 @@
12839
12839
  "format": "esm"
12840
12840
  },
12841
12841
  "src/runtime/async-runner.ts": {
12842
- "bytes": 14599,
12842
+ "bytes": 14704,
12843
12843
  "imports": [
12844
12844
  {
12845
12845
  "path": "node:child_process",
@@ -14676,7 +14676,7 @@
14676
14676
  "format": "esm"
14677
14677
  },
14678
14678
  "src/runtime/task-runner/state-helpers.ts": {
14679
- "bytes": 5519,
14679
+ "bytes": 5684,
14680
14680
  "imports": [
14681
14681
  {
14682
14682
  "path": "node:fs",
@@ -15139,7 +15139,7 @@
15139
15139
  "format": "esm"
15140
15140
  },
15141
15141
  "src/runtime/team-runner.ts": {
15142
- "bytes": 76143,
15142
+ "bytes": 76156,
15143
15143
  "imports": [
15144
15144
  {
15145
15145
  "path": "node:fs",
@@ -18390,7 +18390,7 @@
18390
18390
  "imports": [],
18391
18391
  "exports": [],
18392
18392
  "inputs": {},
18393
- "bytes": 6636234
18393
+ "bytes": 6636624
18394
18394
  },
18395
18395
  "dist/index.mjs": {
18396
18396
  "imports": [
@@ -20589,7 +20589,7 @@
20589
20589
  "bytesInOutput": 12069
20590
20590
  },
20591
20591
  "src/utils/internal-error.ts": {
20592
- "bytesInOutput": 443
20592
+ "bytesInOutput": 502
20593
20593
  },
20594
20594
  "src/utils/sleep.ts": {
20595
20595
  "bytesInOutput": 948
@@ -20598,7 +20598,7 @@
20598
20598
  "bytesInOutput": 5243
20599
20599
  },
20600
20600
  "src/state/atomic-write.ts": {
20601
- "bytesInOutput": 17427
20601
+ "bytesInOutput": 17436
20602
20602
  },
20603
20603
  "src/config/defaults.ts": {
20604
20604
  "bytesInOutput": 4426
@@ -20685,7 +20685,7 @@
20685
20685
  "bytesInOutput": 580
20686
20686
  },
20687
20687
  "src/runtime/crash-recovery.ts": {
20688
- "bytesInOutput": 15270
20688
+ "bytesInOutput": 15302
20689
20689
  },
20690
20690
  "src/runtime/deadletter.ts": {
20691
20691
  "bytesInOutput": 592
@@ -20769,7 +20769,7 @@
20769
20769
  "bytesInOutput": 1801
20770
20770
  },
20771
20771
  "src/runtime/child-pi.ts": {
20772
- "bytesInOutput": 45369
20772
+ "bytesInOutput": 45390
20773
20773
  },
20774
20774
  "src/runtime/heartbeat-gradient.ts": {
20775
20775
  "bytesInOutput": 953
@@ -21447,7 +21447,7 @@
21447
21447
  "bytesInOutput": 8715
21448
21448
  },
21449
21449
  "src/runtime/skill-instructions.ts": {
21450
- "bytesInOutput": 11480
21450
+ "bytesInOutput": 11101
21451
21451
  },
21452
21452
  "src/state/run-cache.ts": {
21453
21453
  "bytesInOutput": 2142
@@ -21507,7 +21507,7 @@
21507
21507
  "bytesInOutput": 2699
21508
21508
  },
21509
21509
  "src/runtime/async-runner.ts": {
21510
- "bytesInOutput": 8931
21510
+ "bytesInOutput": 8942
21511
21511
  },
21512
21512
  "src/subagents/async-entry.ts": {
21513
21513
  "bytesInOutput": 119
@@ -21759,7 +21759,7 @@
21759
21759
  "bytesInOutput": 767
21760
21760
  },
21761
21761
  "src/runtime/task-runner/state-helpers.ts": {
21762
- "bytesInOutput": 2576
21762
+ "bytesInOutput": 2756
21763
21763
  },
21764
21764
  "src/runtime/task-runner/tail-read.ts": {
21765
21765
  "bytesInOutput": 762
@@ -21792,7 +21792,7 @@
21792
21792
  "bytesInOutput": 3398
21793
21793
  },
21794
21794
  "src/runtime/team-runner.ts": {
21795
- "bytesInOutput": 57487
21795
+ "bytesInOutput": 57504
21796
21796
  },
21797
21797
  "src/runtime/pipeline-runner.ts": {
21798
21798
  "bytesInOutput": 12070
@@ -22095,7 +22095,7 @@
22095
22095
  "bytesInOutput": 81
22096
22096
  }
22097
22097
  },
22098
- "bytes": 3161826
22098
+ "bytes": 3161776
22099
22099
  }
22100
22100
  }
22101
22101
  }
package/dist/index.mjs CHANGED
@@ -8493,8 +8493,10 @@ var init_config_schema = __esm({
8493
8493
  });
8494
8494
 
8495
8495
  // src/utils/internal-error.ts
8496
- function logInternalError(scope, error, details) {
8497
- if (!process.env.PI_TEAMS_DEBUG) return;
8496
+ function logInternalError(scope, error, details, severity) {
8497
+ if (!severity || severity === "debug") {
8498
+ if (!process.env.PI_TEAMS_DEBUG) return;
8499
+ }
8498
8500
  const message = error instanceof Error ? error.message : typeof error === "object" && error !== null ? JSON.stringify(error) : String(error);
8499
8501
  const suffix = details ? `: ${details}` : "";
8500
8502
  console.error(`[pi-crew:${scope}] ${message}${suffix}`);
@@ -9096,7 +9098,7 @@ function flushOnePendingAtomicWrite(filePath) {
9096
9098
  pendingAtomicWrites.delete(filePath);
9097
9099
  }
9098
9100
  } catch (error) {
9099
- logInternalError("atomic-write.coalesced-flush", error, filePath);
9101
+ logInternalError("atomic-write.coalesced-flush", error, filePath, "error");
9100
9102
  const current2 = pendingAtomicWrites.get(filePath);
9101
9103
  if (current2?.generation === savedGeneration) {
9102
9104
  current2.retryCount++;
@@ -9145,7 +9147,7 @@ var init_atomic_write = __esm({
9145
9147
  init_worker_atomic_writer();
9146
9148
  RETRYABLE_RENAME_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES"]);
9147
9149
  RETRYABLE_LINK_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES", "ENOENT"]);
9148
- SYMLINK_SAFE_TTL_MS = 3e4;
9150
+ SYMLINK_SAFE_TTL_MS = 1e4;
9149
9151
  SYMLINK_SAFE_MAX_ENTRIES = 128;
9150
9152
  symlinkSafeCache = /* @__PURE__ */ new Map();
9151
9153
  __test__renameWithRetry = renameWithRetry;
@@ -15399,7 +15401,7 @@ function cancelOrphanedRuns(cwd, manifestCache2, currentSessionId, staleThreshol
15399
15401
  });
15400
15402
  if (cancelledRun)
15401
15403
  void terminateLiveAgentsForRun(manifest.runId, "cancelled", appendEvent, loaded.manifest.eventsPath).catch(
15402
- (error) => logInternalError("crash-recovery.orphan.terminate", error, `runId=${manifest.runId}`)
15404
+ (error) => logInternalError("crash-recovery.orphan.terminate", error, `runId=${manifest.runId}`, "warn")
15403
15405
  );
15404
15406
  }
15405
15407
  return { cancelled, skipped };
@@ -15494,7 +15496,7 @@ function purgeStaleActiveRunIndex(staleThresholdMs = 3e5, now = Date.now()) {
15494
15496
  appendEvent,
15495
15497
  fullLoaded.manifest.eventsPath
15496
15498
  ).catch(
15497
- (error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`)
15499
+ (error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`, "warn")
15498
15500
  );
15499
15501
  }
15500
15502
  } catch {
@@ -15539,7 +15541,7 @@ function purgeStaleActiveRunIndex(staleThresholdMs = 3e5, now = Date.now()) {
15539
15541
  appendEvent,
15540
15542
  fullLoaded.manifest.eventsPath
15541
15543
  ).catch(
15542
- (error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`)
15544
+ (error) => logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`, "warn")
15543
15545
  );
15544
15546
  }
15545
15547
  } catch {
@@ -15586,7 +15588,7 @@ function reconcileAllStaleRuns(cwd, manifestCache2, now = Date.now()) {
15586
15588
  }
15587
15589
  updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result4.detail}`);
15588
15590
  void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch(
15589
- (error) => logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`)
15591
+ (error) => logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`, "warn")
15590
15592
  );
15591
15593
  appendEvent(fresh.manifest.eventsPath, {
15592
15594
  type: "crew.run.reconciled_stale",
@@ -18376,7 +18378,8 @@ function killProcessPid(pid) {
18376
18378
  logInternalError(
18377
18379
  "child-pi.taskkill-stuck",
18378
18380
  new Error(`process ${pid} still alive 2s after taskkill /T /F; retrying`),
18379
- `pid=${pid}`
18381
+ `pid=${pid}`,
18382
+ "error"
18380
18383
  );
18381
18384
  try {
18382
18385
  spawnTaskkillSafe(pid);
@@ -44860,13 +44863,9 @@ var init_skill_instructions = __esm({
44860
44863
  critic: ["read-only-explorer", "multi-perspective-review"],
44861
44864
  executor: ["state-mutation-locking", "safe-bash", "verification-before-done"],
44862
44865
  reviewer: ["read-only-explorer", "multi-perspective-review"],
44863
- // SECURITY NOTE: The following skill names are trusted package-level skills.
44864
- // If a project has a skills/ directory containing subdirectories with these names,
44865
- // those project-level SKILL.md files will be FOUND FIRST (readSkillMarkdown checks
44866
- // project dir before package dir) and their content injected verbatim into prompts.
44867
- // The "Applicable Skills" block will add an untrusted-content warning for project skills,
44868
- // but be aware this is a potential supply-chain risk in multi-contributor projects.
44869
44866
  "security-reviewer": ["secure-agent-orchestration-review", "ownership-session-security"],
44867
+ // SECURITY NOTE: Package skills are checked FIRST (SEC-003). Project-level
44868
+ // skills with the same name will NOT override the trusted package version.
44870
44869
  "test-engineer": ["verification-before-done", "safe-bash"],
44871
44870
  verifier: ["verification-before-done", "runtime-state-reader"],
44872
44871
  writer: ["context-artifact-hygiene", "verification-before-done"]
@@ -47999,7 +47998,7 @@ async function spawnBackgroundTeamRun(manifest) {
47999
47998
  const loader = resolveTypeScriptLoader();
48000
47999
  if (!loader) {
48001
48000
  const message = buildLoaderUnavailableMessage(packageRootFromRuntime());
48002
- appendEvent(manifest.eventsPath, {
48001
+ await appendEventAsync(manifest.eventsPath, {
48003
48002
  type: "async.failed",
48004
48003
  runId: manifest.runId,
48005
48004
  message
@@ -57483,6 +57482,7 @@ function updateTask(tasks, updated) {
57483
57482
  return tasks.map((task) => task.id === updated.id ? updated : task);
57484
57483
  }
57485
57484
  function persistSingleTaskUpdate(manifest, fallbackTasks, updated, checkpointPhase) {
57485
+ const MAX_CAS_ATTEMPTS = 100;
57486
57486
  let baseMtime = 0;
57487
57487
  try {
57488
57488
  baseMtime = fs80.statSync(manifest.tasksPath).mtimeMs;
@@ -57499,7 +57499,7 @@ function persistSingleTaskUpdate(manifest, fallbackTasks, updated, checkpointPha
57499
57499
  } : updated;
57500
57500
  try {
57501
57501
  return withRunLockSync(manifest, () => {
57502
- for (let attempt = 0; attempt < 100; attempt++) {
57502
+ for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
57503
57503
  flushPendingAtomicWrites();
57504
57504
  const latest = loadRunManifestById(manifest.cwd, manifest.runId)?.tasks ?? fallbackTasks;
57505
57505
  merged = updateTask(latest, taskWithCheckpoint);
@@ -57516,20 +57516,25 @@ function persistSingleTaskUpdate(manifest, fallbackTasks, updated, checkpointPha
57516
57516
  break;
57517
57517
  }
57518
57518
  if (merged === void 0) {
57519
- logInternalError("persistSingleTaskUpdate", new Error("failed to converge after 50 attempts"));
57520
- throw new Error("persistSingleTaskUpdate: failed to converge after 50 attempts");
57519
+ logInternalError(
57520
+ "persistSingleTaskUpdate",
57521
+ new Error(`failed to converge after ${MAX_CAS_ATTEMPTS} attempts`),
57522
+ void 0,
57523
+ "error"
57524
+ );
57525
+ throw new Error(`persistSingleTaskUpdate: failed to converge after ${MAX_CAS_ATTEMPTS} attempts`);
57521
57526
  }
57522
57527
  try {
57523
57528
  saveRunTasksCoalesced(manifest, merged);
57524
57529
  } catch (err2) {
57525
- logInternalError("persistSingleTaskUpdate", err2);
57530
+ logInternalError("persistSingleTaskUpdate", err2, void 0, "error");
57526
57531
  throw err2;
57527
57532
  }
57528
57533
  return merged;
57529
57534
  });
57530
57535
  } catch (err2) {
57531
57536
  if (merged === void 0) {
57532
- logInternalError("persistSingleTaskUpdate", err2);
57537
+ logInternalError("persistSingleTaskUpdate", err2, void 0, "error");
57533
57538
  }
57534
57539
  throw err2;
57535
57540
  }
@@ -60356,7 +60361,8 @@ async function executeTeamRun(input) {
60356
60361
  logInternalError(
60357
60362
  "team-runner.goalAchievement.falseGreen",
60358
60363
  new Error(gaApplied.manifest.goalAchievementNote ?? "false-green detected"),
60359
- `runId=${manifest.runId}`
60364
+ `runId=${manifest.runId}`,
60365
+ "error"
60360
60366
  );
60361
60367
  stopTeamHeartbeat();
60362
60368
  resolveRunPromise(manifest.runId, result4);