pi-crew 0.9.29 → 0.9.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.29",
3
+ "version": "0.9.30",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -1,5 +1,8 @@
1
1
  import * as fs from "node:fs";
2
+ import * as path from "node:path";
2
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { logInternalError } from "../utils/internal-error.ts";
5
+ import { resolveRealContainedPath } from "../utils/safe-paths.ts";
3
6
 
4
7
  export const PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV = "PI_TEAMS_INHERIT_PROJECT_CONTEXT";
5
8
  export const PI_TEAMS_INHERIT_SKILLS_ENV = "PI_TEAMS_INHERIT_SKILLS";
@@ -12,6 +15,99 @@ const PROJECT_CONTEXT_HEADER = "\n\n# Project Context\n\nProject-specific instru
12
15
  const SKILLS_HEADER = "\n\nThe following skills provide specialized instructions for specific tasks.";
13
16
  const DATE_HEADER = "\nCurrent date:";
14
17
 
18
+ // ── FIX-02: Steering content sanitization limits ──────────────────────────
19
+ // Bounded to keep a malformed/malicious steer entry from blowing up the
20
+ // worker's prompt budget or smuggling control sequences into the agent.
21
+ const MAX_STEER_MESSAGE_LENGTH = 4096;
22
+ const MAX_STEER_MESSAGE_NEWLINES = 50;
23
+ // C0 control characters minus the printable whitespace (\t \n \r). These
24
+ // are the bytes most useful for ANSI escapes, terminal-control tricks, and
25
+ // NUL-injection attacks when steer content reaches the worker's UI.
26
+ const STEER_CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
27
+
28
+ export interface SteerSanitizeResult {
29
+ valid: boolean;
30
+ reason?: string;
31
+ message?: string;
32
+ }
33
+
34
+ export interface SteerEntry {
35
+ type?: string;
36
+ message?: string;
37
+ }
38
+
39
+ /**
40
+ * Validate a single steering-file entry before forwarding it to
41
+ * `pi.sendMessage`. FIX-02: reject oversized payloads, excessive newlines,
42
+ * or control characters that could be used to confuse the worker UI.
43
+ */
44
+ export function sanitizeSteerMessage(entry: SteerEntry): SteerSanitizeResult {
45
+ const message = entry.message;
46
+ if (typeof message !== "string" || message.length === 0) {
47
+ return { valid: false, reason: "missing-or-empty-message" };
48
+ }
49
+ if (message.length > MAX_STEER_MESSAGE_LENGTH) {
50
+ return { valid: false, reason: `message-too-long:${message.length}` };
51
+ }
52
+ const newlineCount = (message.match(/\n/g) ?? []).length;
53
+ if (newlineCount > MAX_STEER_MESSAGE_NEWLINES) {
54
+ return { valid: false, reason: `too-many-newlines:${newlineCount}` };
55
+ }
56
+ if (STEER_CONTROL_CHAR_PATTERN.test(message)) {
57
+ return { valid: false, reason: "contains-control-characters" };
58
+ }
59
+ return { valid: true, message };
60
+ }
61
+
62
+ // ── FIX-03: Steering file path containment validation ─────────────────────
63
+ // The steering file path is inherited from the parent via env, so we
64
+ // defensively re-validate it before the first read to catch symlink
65
+ // redirection or paths that escape the session's artifacts root.
66
+ export interface SteeringFileValidation {
67
+ valid: boolean;
68
+ reason?: string;
69
+ resolvedPath?: string;
70
+ }
71
+
72
+ /**
73
+ * Validate `PI_CREW_STEERING_FILE` before first read. FIX-03:
74
+ * 1. `lstatSync` rejects a symlink at the steering file itself.
75
+ * 2. `resolveRealContainedPath` walks the ancestor chain with O_NOFOLLOW
76
+ * to reject any symlinked parent (e.g. a redirected `artifactsRoot`)
77
+ * and to verify the resolved path stays inside the derived artifacts
78
+ * root (`<artifactsRoot>/steering/<taskId>.jsonl` → `<artifactsRoot>`).
79
+ *
80
+ * Returns `{ valid: false }` with a reason on any violation. Callers must
81
+ * log + skip steering on failure rather than abort the worker.
82
+ */
83
+ export function validateSteeringFile(steeringFile: string): SteeringFileValidation {
84
+ try {
85
+ const lst = fs.lstatSync(steeringFile);
86
+ if (lst.isSymbolicLink()) {
87
+ return { valid: false, reason: "steering-file-is-symlink" };
88
+ }
89
+ } catch (error) {
90
+ const errCode = (error as NodeJS.ErrnoException).code;
91
+ if (errCode && errCode !== "ENOENT") {
92
+ return { valid: false, reason: `lstat-failed:${errCode}` };
93
+ }
94
+ }
95
+ // Layout invariant from task-runner.ts: `steeringFile` is built as
96
+ // `<artifactsRoot>/steering/<taskId>.jsonl`. We don't trust the caller
97
+ // to pass `artifactsRoot`, so derive it as 2 levels up. `resolveRealContainedPath`
98
+ // then enforces both containment AND ancestor-symlink safety in one shot.
99
+ const artifactsRoot = path.resolve(steeringFile, "..", "..");
100
+ try {
101
+ const resolved = resolveRealContainedPath(artifactsRoot, steeringFile);
102
+ return { valid: true, resolvedPath: resolved };
103
+ } catch (error) {
104
+ return {
105
+ valid: false,
106
+ reason: `path-validation-failed:${error instanceof Error ? error.message : String(error)}`,
107
+ };
108
+ }
109
+ }
110
+
15
111
  function readBooleanEnv(name: string): boolean | undefined {
16
112
  const value = process.env[name];
17
113
  if (value === undefined) return undefined;
@@ -83,43 +179,67 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
83
179
  // the active session via pi.sendMessage with deliverAs:"steer".
84
180
  const steeringFile = process.env[PI_CREW_STEERING_FILE_ENV];
85
181
  if (steeringFile) {
86
- let lastOffset = 0;
87
- const pollSteering = (): void => {
88
- try {
89
- const stat = fs.statSync(steeringFile, { throwIfNoEntry: false });
90
- if (!stat || stat.size <= lastOffset) return;
91
- const fd = fs.openSync(steeringFile, "r");
182
+ // FIX-03: validate the steering file path once before first read.
183
+ const validation = validateSteeringFile(steeringFile);
184
+ if (!validation.valid) {
185
+ logInternalError(
186
+ "prompt-runtime.steering-file-rejected",
187
+ new Error(validation.reason ?? "steering-file-validation-failed"),
188
+ `path=${steeringFile}`,
189
+ "warn",
190
+ );
191
+ } else {
192
+ const safeSteeringFile = validation.resolvedPath ?? steeringFile;
193
+ let lastOffset = 0;
194
+ const pollSteering = (): void => {
92
195
  try {
93
- const buf = Buffer.alloc(stat.size - lastOffset);
94
- fs.readSync(fd, buf, 0, buf.length, lastOffset);
95
- lastOffset = stat.size;
96
- const lines = buf.toString("utf8").split("\n").filter(Boolean);
97
- for (const line of lines) {
98
- try {
99
- const entry = JSON.parse(line) as { type?: string; message?: string };
100
- if (entry.type === "steer" && entry.message) {
196
+ const stat = fs.statSync(safeSteeringFile, { throwIfNoEntry: false });
197
+ if (!stat || stat.size <= lastOffset) return;
198
+ const fd = fs.openSync(safeSteeringFile, "r");
199
+ try {
200
+ const buf = Buffer.alloc(stat.size - lastOffset);
201
+ fs.readSync(fd, buf, 0, buf.length, lastOffset);
202
+ lastOffset = stat.size;
203
+ const lines = buf.toString("utf8").split("\n").filter(Boolean);
204
+ for (const line of lines) {
205
+ try {
206
+ const entry = JSON.parse(line) as SteerEntry;
207
+ if (entry.type !== "steer") continue;
208
+ // FIX-02: sanitize each steer entry before forwarding
209
+ // to pi.sendMessage. Reject oversized payloads,
210
+ // excessive newlines, and control characters.
211
+ const sanitized = sanitizeSteerMessage(entry);
212
+ if (!sanitized.valid || sanitized.message === undefined) {
213
+ logInternalError(
214
+ "prompt-runtime.steer-rejected",
215
+ new Error(sanitized.reason ?? "steer-sanitization-failed"),
216
+ `line-preview=${line.slice(0, 64)}`,
217
+ "warn",
218
+ );
219
+ continue;
220
+ }
101
221
  pi.sendMessage(
102
- { customType: "crew-steer", content: entry.message, display: false },
222
+ { customType: "crew-steer", content: sanitized.message, display: false },
103
223
  { deliverAs: "steer" },
104
224
  );
225
+ } catch {
226
+ // Malformed line — skip
105
227
  }
228
+ }
229
+ } finally {
230
+ try {
231
+ fs.closeSync(fd);
106
232
  } catch {
107
- // Malformed line — skip
233
+ /* already closed */
108
234
  }
109
235
  }
110
- } finally {
111
- try {
112
- fs.closeSync(fd);
113
- } catch {
114
- /* already closed */
115
- }
236
+ } catch {
237
+ // File doesn't exist yet or read error — will retry next tick
116
238
  }
117
- } catch {
118
- // File doesn't exist yet or read error — will retry next tick
119
- }
120
- };
121
- const timer = setInterval(pollSteering, 500);
122
- timer.unref?.();
239
+ };
240
+ const timer = setInterval(pollSteering, 500);
241
+ timer.unref?.();
242
+ }
123
243
  }
124
244
 
125
245
  // ── Prompt rewriting (existing) ────────────────────────────────────────
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { appendEvent } from "../state/event-log.ts";
6
+ import { appendEvent, appendEventAsync } from "../state/event-log.ts";
7
7
  import type { TeamRunManifest } from "../state/types.ts";
8
8
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
9
9
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
@@ -257,7 +257,8 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
257
257
  const loader = resolveTypeScriptLoader();
258
258
  if (!loader) {
259
259
  const message = buildLoaderUnavailableMessage(packageRootFromRuntime());
260
- appendEvent(manifest.eventsPath, {
260
+ // FIX-08: use async event append to avoid sleepSync event-loop blocking.
261
+ await appendEventAsync(manifest.eventsPath, {
261
262
  type: "async.failed",
262
263
  runId: manifest.runId,
263
264
  message,
@@ -112,6 +112,7 @@ export function killProcessPid(pid: number): void {
112
112
  "child-pi.taskkill-stuck",
113
113
  new Error(`process ${pid} still alive 2s after taskkill /T /F; retrying`),
114
114
  `pid=${pid}`,
115
+ "error",
115
116
  );
116
117
  try {
117
118
  spawnTaskkillSafe(pid);
@@ -244,7 +244,7 @@ export function cancelOrphanedRuns(
244
244
  });
245
245
  if (cancelledRun)
246
246
  void terminateLiveAgentsForRun(manifest.runId, "cancelled", appendEvent, loaded.manifest.eventsPath).catch((error) =>
247
- logInternalError("crash-recovery.orphan.terminate", error, `runId=${manifest.runId}`),
247
+ logInternalError("crash-recovery.orphan.terminate", error, `runId=${manifest.runId}`, "warn"),
248
248
  );
249
249
  }
250
250
 
@@ -428,7 +428,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
428
428
  appendEvent,
429
429
  fullLoaded.manifest.eventsPath,
430
430
  ).catch((error) =>
431
- logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`),
431
+ logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`, "warn"),
432
432
  );
433
433
  }
434
434
  } catch {
@@ -482,7 +482,7 @@ export function purgeStaleActiveRunIndex(staleThresholdMs = 300_000, now = Date.
482
482
  appendEvent,
483
483
  fullLoaded.manifest.eventsPath,
484
484
  ).catch((error) =>
485
- logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`),
485
+ logInternalError("crash-recovery.pid-dead.terminate", error, `runId=${fullLoaded.manifest.runId}`, "warn"),
486
486
  );
487
487
  }
488
488
  } catch {
@@ -543,7 +543,7 @@ export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache,
543
543
  }
544
544
  updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
545
545
  void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) =>
546
- logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`),
546
+ logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`, "warn"),
547
547
  );
548
548
  appendEvent(fresh.manifest.eventsPath, {
549
549
  type: "crew.run.reconciled_stale",
@@ -29,13 +29,9 @@ const DEFAULT_ROLE_SKILLS: Record<string, string[]> = {
29
29
  critic: ["read-only-explorer", "multi-perspective-review"],
30
30
  executor: ["state-mutation-locking", "safe-bash", "verification-before-done"],
31
31
  reviewer: ["read-only-explorer", "multi-perspective-review"],
32
- // SECURITY NOTE: The following skill names are trusted package-level skills.
33
- // If a project has a skills/ directory containing subdirectories with these names,
34
- // those project-level SKILL.md files will be FOUND FIRST (readSkillMarkdown checks
35
- // project dir before package dir) and their content injected verbatim into prompts.
36
- // The "Applicable Skills" block will add an untrusted-content warning for project skills,
37
- // but be aware this is a potential supply-chain risk in multi-contributor projects.
38
32
  "security-reviewer": ["secure-agent-orchestration-review", "ownership-session-security"],
33
+ // SECURITY NOTE: Package skills are checked FIRST (SEC-003). Project-level
34
+ // skills with the same name will NOT override the trusted package version.
39
35
  "test-engineer": ["verification-before-done", "safe-bash"],
40
36
  verifier: ["verification-before-done", "runtime-state-reader"],
41
37
  writer: ["context-artifact-hygiene", "verification-before-done"],
@@ -32,6 +32,7 @@ export function persistSingleTaskUpdate(
32
32
  updated: TeamTaskState,
33
33
  checkpointPhase?: TaskCheckpointState["phase"],
34
34
  ): TeamTaskState[] {
35
+ const MAX_CAS_ATTEMPTS = 100;
35
36
  let baseMtime = 0;
36
37
  try {
37
38
  baseMtime = fs.statSync(manifest.tasksPath).mtimeMs;
@@ -55,7 +56,7 @@ export function persistSingleTaskUpdate(
55
56
 
56
57
  try {
57
58
  return withRunLockSync(manifest, () => {
58
- for (let attempt = 0; attempt < 100; attempt++) {
59
+ for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
59
60
  // F4: persistSingleTaskUpdate now uses saveRunTasksCoalesced below
60
61
  // (50ms debounce window). Read-modify-write loops are unsafe under
61
62
  // coalescing — a parallel writer's buffered write is invisible to
@@ -93,8 +94,13 @@ export function persistSingleTaskUpdate(
93
94
  }
94
95
 
95
96
  if (merged === undefined) {
96
- logInternalError("persistSingleTaskUpdate", new Error("failed to converge after 50 attempts"));
97
- throw new Error("persistSingleTaskUpdate: failed to converge after 50 attempts");
97
+ logInternalError(
98
+ "persistSingleTaskUpdate",
99
+ new Error(`failed to converge after ${MAX_CAS_ATTEMPTS} attempts`),
100
+ undefined,
101
+ "error",
102
+ );
103
+ throw new Error(`persistSingleTaskUpdate: failed to converge after ${MAX_CAS_ATTEMPTS} attempts`);
98
104
  }
99
105
 
100
106
  try {
@@ -105,14 +111,14 @@ export function persistSingleTaskUpdate(
105
111
  // writer's flushed-before-this-call state.
106
112
  saveRunTasksCoalesced(manifest, merged);
107
113
  } catch (err) {
108
- logInternalError("persistSingleTaskUpdate", err);
114
+ logInternalError("persistSingleTaskUpdate", err, undefined, "error");
109
115
  throw err;
110
116
  }
111
117
  return merged;
112
118
  });
113
119
  } catch (err) {
114
120
  if (merged === undefined) {
115
- logInternalError("persistSingleTaskUpdate", err);
121
+ logInternalError("persistSingleTaskUpdate", err, undefined, "error");
116
122
  }
117
123
  throw err;
118
124
  }
@@ -704,6 +704,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
704
704
  "team-runner.goalAchievement.falseGreen",
705
705
  new Error(gaApplied.manifest.goalAchievementNote ?? "false-green detected"),
706
706
  `runId=${manifest.runId}`,
707
+ "error",
707
708
  );
708
709
  stopTeamHeartbeat();
709
710
  resolveRunPromise(manifest.runId, result);
@@ -166,12 +166,12 @@ export function isSymlinkSafePath(filePath: string): boolean {
166
166
  // parent dirs of `.crew/state/runs/{runId}/` are stable within a run, so we
167
167
  // cache the ancestor-walk verdict keyed by `dirname(filePath)`.
168
168
  //
169
- // SECURITY: the *target file* itself is still checked every call (no cache).
170
- // Caching is per-dir at a short TTL. If a dir is mutated mid-window, the cache
171
- // is invalidated explicitly via `invalidateSymlinkSafeCache(dir)` from any
172
- // file-create / rename hook, plus a TTL failsafe. The cache is best-effort
173
- // speed-up — when in doubt the next call's statSync re-verifies.
174
- const SYMLINK_SAFE_TTL_MS = 30_000;
169
+ // SECURITY: TTL-only invalidation. The `.crew/` state directory is trusted
170
+ // (single-user, non-shared). Symlink attacks require write access to the state
171
+ // dir, which would already compromise the system. The short TTL provides
172
+ // defense-in-depth. The target file itself is still checked every call (no
173
+ // cache) — when in doubt the next call's statSync re-verifies.
174
+ const SYMLINK_SAFE_TTL_MS = 10_000;
175
175
  const SYMLINK_SAFE_MAX_ENTRIES = 128;
176
176
  // NOTE: This cache is process-local and assumes single-threaded access.
177
177
  // If worker-thread usage expands (e.g., worker-atomic-writer.ts), this cache
@@ -731,7 +731,7 @@ function flushOnePendingAtomicWrite(filePath: string): void {
731
731
  pendingAtomicWrites.delete(filePath);
732
732
  }
733
733
  } catch (error) {
734
- logInternalError("atomic-write.coalesced-flush", error, filePath);
734
+ logInternalError("atomic-write.coalesced-flush", error, filePath, "error");
735
735
  // Issue 1 fix: set a fresh timer for failed entries before returning.
736
736
  // This ensures failed entries are retried without waiting for another
737
737
  // write to arrive. Only set timer if this entry is still current
@@ -1,5 +1,8 @@
1
- export function logInternalError(scope: string, error: unknown, details?: string): void {
2
- if (!process.env.PI_TEAMS_DEBUG) return;
1
+ export function logInternalError(scope: string, error: unknown, details?: string, severity?: "error" | "warn" | "debug"): void {
2
+ // "error" and "warn" always emit; "debug" (default) is gated behind PI_TEAMS_DEBUG
3
+ if (!severity || severity === "debug") {
4
+ if (!process.env.PI_TEAMS_DEBUG) return;
5
+ }
3
6
  const message =
4
7
  error instanceof Error ? error.message : typeof error === "object" && error !== null ? JSON.stringify(error) : String(error);
5
8
  const suffix = details ? `: ${details}` : "";