pi-crew 0.5.6 → 0.5.7

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,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.7] — 11 Issue Fixes Across 5 Phases (2026-06-01)
4
+
5
+ ### Phase 1: Schema/Type Fixes
6
+
7
+ - **`invalidate` schema divergence** (Critical) — `src/schema/team-tool-schema.ts`: added `"invalidate"` to TypeBox union. Previously TS interface had it but TypeBox schema did not, causing silent `-32602` failure.
8
+ - **OTLP header key validation** (Low) — `src/config/config.ts`: hardened `parseOtlpConfig` with case-insensitive check for 12 dangerous keys (`__proto__`, `hasOwnProperty`, `toString`, etc.) and format validation `/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/`.
9
+
10
+ ### Phase 2: Security Hardening
11
+
12
+ - **OTLP endpoint unsanitized** (Critical) — `src/config/config.ts`: project config can no longer override `otlp.endpoint` (would have allowed credential exfiltration via attacker URL).
13
+ - **Wildcard env leakage** (High) — `src/runtime/child-pi.ts`: replaced broad wildcards (`LC_*`, `XDG_*`, `NVM_*`, `NODE_*`, `npm_*`) with specific names. Previously `NPM_TOKEN`, `NODE_ENV=production`, `NVM_RC_VERSION` all leaked.
14
+
15
+ ### Phase 3: Correctness Fixes
16
+
17
+ - **AbortSignal not propagated** (High) — `src/runtime/task-runner.ts`: check signal before `persistSingleTaskUpdate`. Cancelled tasks now return early with cancelled status instead of writing stale state.
18
+ - **MAILBOX_ARCHIVE_THRESHOLD 10MB/task** (High) — `src/state/mailbox.ts` + `src/config/defaults.ts`: added `DEFAULT_MAILBOX.maxArchivesPerDirection=10` cap and `pruneOldMailboxArchives()` to prevent unbounded growth (1GB+ for 100 tasks).
19
+ - **`safeRm` regex bypass** (Medium) — `src/tools/safe-bash.ts`: stricter regex requires path to be exactly `tmp/`, `cache/`, `node_modules/`, `dist/`, or `build/` with optional `./` prefix. Rejects path traversal like `./../../../etc`.
20
+ - **`writeEntries` silent drop** (Medium) — `src/state/active-run-registry.ts`: emit `logInternalError` warning when entries overflow cap.
21
+
22
+ ### Phase 4: Performance Optimization
23
+
24
+ - **`nextAgentEventSeq` O(n) cold cache** (Medium) — `src/runtime/crew-agent-records.ts`: added `.seq` sidecar file for O(1) lookup. Fall back to O(n) scan only when sidecar is missing.
25
+ - **`nextSequence` O(n) cold cache** (Medium) — `src/state/event-log.ts`: trust sidecar seq file when present. Fall back to `scanSequence` only when sidecar missing or file shrunk.
26
+
27
+ ### Phase 5: Deferred (Low severity)
28
+
29
+ - **Issue #12: `acquireLockWithRetry` race** — defer (race window small, retry loop handles).
30
+ - **Issue #13: `loadRunManifestById` TOCTOU** — defer (cache TTL 30s, race window small).
31
+ - **Issue #14: `cleanupOldArtifacts` N stat calls** — defer (typical artifact dirs small).
32
+ - **Issue #15: `validateMailbox` full load** — defer (10MB cap, bounded).
33
+ - **Issue #16: `updateMailboxMessageReply` full rewrite** — defer (10MB cap, bounded).
34
+
35
+ ### Tests
36
+
37
+ - 2282 tests pass / 0 failures (`npm test`).
38
+ - New tests: `invalidate`/`anchor`/`auto-summarize`/`auto_boomerang` schema, OTLP header key validation, OTLP endpoint sanitization, wildcard env leakage, sidecar seq lookup.
39
+
3
40
  ## [0.5.6] — Documentation Sync + Type-Only Import Fix (2026-06-01)
4
41
 
5
42
  ### Documentation
package/README.md CHANGED
@@ -9,7 +9,7 @@ npm: pi-crew
9
9
  repo: https://github.com/baphuongna/pi-crew
10
10
  ```
11
11
 
12
- **v0.5.6**: See [CHANGELOG.md](CHANGELOG.md).
12
+ **v0.5.7**: See [CHANGELOG.md](CHANGELOG.md).
13
13
 
14
14
  ### Security highlights (v0.5.5)
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -244,6 +244,15 @@ function sanitizeProjectConfig(
244
244
  sanitized.otlp = undefined;
245
245
  warnings.push(projectOverrideWarning(projectPath, "otlp.headers"));
246
246
  }
247
+ // FIX: Block project config from setting otlp.endpoint — it controls where
248
+ // OTLP headers (potentially containing credentials) are sent.
249
+ if (config.otlp?.endpoint !== undefined) {
250
+ if (!sanitized.otlp) sanitized.otlp = { ...config.otlp, endpoint: undefined };
251
+ else sanitized.otlp = { ...sanitized.otlp, endpoint: undefined };
252
+ if (!Object.values(sanitized.otlp).some((entry) => entry !== undefined))
253
+ sanitized.otlp = undefined;
254
+ warnings.push(projectOverrideWarning(projectPath, "otlp.endpoint"));
255
+ }
247
256
  if (
248
257
  config.agents?.disableBuiltins !== undefined ||
249
258
  config.agents?.overrides !== undefined
@@ -1051,13 +1060,28 @@ function parseOtlpConfig(value: unknown): CrewOtlpConfig | undefined {
1051
1060
  if (rawHeaders)
1052
1061
  for (const [key, entry] of Object.entries(rawHeaders)) {
1053
1062
  if (typeof entry !== "string") continue;
1054
- // Prevent prototype pollution via __proto__ / constructor / prototype keys.
1063
+ // Prevent prototype pollution via dangerous Object.prototype keys.
1064
+ // Case-insensitive check to catch __Proto__, CONSTRUCTOR, etc.
1065
+ const lowerKey = key.toLowerCase();
1055
1066
  if (
1056
- key === "__proto__" ||
1057
- key === "constructor" ||
1058
- key === "prototype"
1067
+ lowerKey === "__proto__" ||
1068
+ lowerKey === "constructor" ||
1069
+ lowerKey === "prototype" ||
1070
+ lowerKey === "hasownproperty" ||
1071
+ lowerKey === "tostring" ||
1072
+ lowerKey === "valueof" ||
1073
+ lowerKey === "isprototypeof" ||
1074
+ lowerKey === "propertyisenumerable" ||
1075
+ lowerKey === "tolocalestring" ||
1076
+ lowerKey === "__definegetter__" ||
1077
+ lowerKey === "__definesetter__" ||
1078
+ lowerKey === "__lookupgetter__" ||
1079
+ lowerKey === "__lookupsetter__"
1059
1080
  )
1060
1081
  continue;
1082
+ // Validate key format: must start with letter, then alphanumeric/hyphen/underscore.
1083
+ // Blocks CRLF, NUL, spaces, shell metacharacters in header keys.
1084
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]{0,127}$/.test(key)) continue;
1061
1085
  headers[key] = entry;
1062
1086
  }
1063
1087
  const otlp: CrewOtlpConfig = {
@@ -91,6 +91,11 @@ export const DEFAULT_CACHE = {
91
91
  manifestMaxEntries: 64,
92
92
  };
93
93
 
94
+ export const DEFAULT_MAILBOX = {
95
+ perFileThresholdBytes: 10 * 1024 * 1024, // 10MB per mailbox file
96
+ maxArchivesPerDirection: 10, // Keep at most 10 archives per direction per run
97
+ };
98
+
94
99
  export const DEFAULT_SUBAGENT = {
95
100
  stuckBlockedNotifyMs: 5 * 60_000,
96
101
  };
@@ -206,11 +206,29 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
206
206
  "SHELL",
207
207
  "TERM",
208
208
  "LANG",
209
- "LC_*",
210
- "XDG_*",
211
- "NVM_*",
212
- "NODE_*",
213
- "npm_*",
209
+ // FIX: Replaced broad wildcards (LC_*, XDG_*, NVM_*, NODE_*, npm_*) with
210
+ // specific names. Previously NPM_TOKEN, NODE_ENV=production, NVM_RC_VERSION
211
+ // all leaked through wildcards.
212
+ "LC_ALL",
213
+ "LC_COLLATE",
214
+ "LC_CTYPE",
215
+ "LC_MESSAGES",
216
+ "LC_MONETARY",
217
+ "LC_NUMERIC",
218
+ "LC_TIME",
219
+ "XDG_CONFIG_HOME",
220
+ "XDG_DATA_HOME",
221
+ "XDG_CACHE_HOME",
222
+ "XDG_RUNTIME_DIR",
223
+ "NVM_BIN",
224
+ "NVM_DIR",
225
+ "NVM_INC",
226
+ "NODE_PATH",
227
+ "NODE_DISABLE_COLORS",
228
+ "NODE_EXTRA_CA_CERTS",
229
+ "NPM_CONFIG_REGISTRY",
230
+ "NPM_CONFIG_USERCONFIG",
231
+ "NPM_CONFIG_GLOBALCONFIG",
214
232
  "PI_*",
215
233
  "PI_CREW_*",
216
234
  "PI_TEAMS_*",
@@ -263,12 +263,41 @@ export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: st
263
263
  }
264
264
 
265
265
  const agentEventSeqCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
266
+ const AGENT_EVENT_SEQ_SIDECAR = ".seq";
267
+
268
+ function readSeqFromSidecar(filePath: string): number | undefined {
269
+ try {
270
+ const raw = fs.readFileSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, "utf-8");
271
+ const n = Number.parseInt(raw, 10);
272
+ return Number.isFinite(n) && n > 0 ? n : undefined;
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ function writeSeqToSidecar(filePath: string, seq: number): void {
279
+ try {
280
+ fs.writeFileSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, String(seq));
281
+ } catch (error) {
282
+ logInternalError("crew-agent-records.seq-sidecar", error, `filePath=${filePath}`);
283
+ }
284
+ }
266
285
 
267
286
  function nextAgentEventSeq(filePath: string): number {
268
- if (!fs.existsSync(filePath)) return 1;
287
+ if (!fs.existsSync(filePath)) {
288
+ // Clean up stale sidecar when main file is gone.
289
+ try { fs.unlinkSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`); } catch {}
290
+ return 1;
291
+ }
269
292
  const stat = fs.statSync(filePath);
270
293
  const cached = agentEventSeqCache.get(filePath);
271
294
  if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.seq + 1;
295
+ // FIX: Try sidecar file for O(1) lookup before falling back to O(n) scan.
296
+ const sidecarSeq = readSeqFromSidecar(filePath);
297
+ if (sidecarSeq !== undefined) {
298
+ agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: sidecarSeq });
299
+ return sidecarSeq + 1;
300
+ }
272
301
  let max = 0;
273
302
  for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
274
303
  if (!line.trim()) continue;
@@ -281,6 +310,7 @@ function nextAgentEventSeq(filePath: string): number {
281
310
  }
282
311
  }
283
312
  agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: max });
313
+ writeSeqToSidecar(filePath, max);
284
314
  return max + 1;
285
315
  }
286
316
 
@@ -292,6 +322,7 @@ export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string,
292
322
  try {
293
323
  const stat = fs.statSync(filePath);
294
324
  agentEventSeqCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
325
+ writeSeqToSidecar(filePath, seq);
295
326
  } catch (error) {
296
327
  logInternalError("crew-agent-records.stat", error, `filePath=${filePath}`);
297
328
  }
@@ -205,6 +205,20 @@ export async function runTeamTask(
205
205
  input.taskRuntimeOverride ??
206
206
  input.runtimeKind ??
207
207
  (input.executeWorkers ? "child-process" : "scaffold");
208
+ // FIX: Check signal before persisting state — if cancelled, skip the write.
209
+ if (input.signal?.aborted) {
210
+ const cancelReason = cancellationReasonFromSignal(input.signal);
211
+ const cancelledTask: TeamTaskState = {
212
+ ...task,
213
+ status: "cancelled",
214
+ error: `${cancelReason.code}: ${cancelReason.message}`,
215
+ finishedAt: new Date().toISOString(),
216
+ };
217
+ return {
218
+ manifest: input.manifest,
219
+ tasks: updateTask(tasks, cancelledTask),
220
+ };
221
+ }
208
222
  tasks = persistSingleTaskUpdate(manifest, tasks, task);
209
223
  if (runtimeKind === "child-process")
210
224
  ({ task, tasks } = checkpointTask(
@@ -58,6 +58,7 @@ export const TeamToolParams = Type.Object({
58
58
  Type.Literal("api"),
59
59
  Type.Literal("settings"),
60
60
  Type.Literal("steer"),
61
+ Type.Literal("invalidate"),
61
62
  Type.Literal("health"),
62
63
  Type.Literal("graph"),
63
64
  Type.Literal("onboard"),
@@ -135,7 +135,16 @@ export function readActiveRunRegistry(maxEntries = DEFAULT_CACHE.manifestMaxEntr
135
135
  }
136
136
 
137
137
  function writeEntries(entries: ActiveRunRegistryEntry[]): void {
138
- const trimmed = entries.slice(0, DEFAULT_CACHE.manifestMaxEntries);
138
+ const max = DEFAULT_CACHE.manifestMaxEntries;
139
+ // FIX: Emit warning when entries overflow the cap, instead of silent drop.
140
+ if (entries.length > max) {
141
+ logInternalError(
142
+ "active-run-registry.overflow",
143
+ new Error(`${entries.length - max} entries dropped (cap=${max})`),
144
+ JSON.stringify({ dropped: entries.length - max, total: entries.length, cap: max }),
145
+ );
146
+ }
147
+ const trimmed = entries.slice(0, max);
139
148
  fs.mkdirSync(path.dirname(registryPath()), { recursive: true });
140
149
  // 2.4 — dual-ship: write both formats. Readers prefer binary; legacy
141
150
  // readers (other tools / older releases) keep using the JSON file.
@@ -167,11 +167,16 @@ function nextSequence(eventsPath: string): number {
167
167
  if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
168
168
  return cached.seq + 1;
169
169
  }
170
- let current = readStoredSequence(eventsPath);
171
- if (current === undefined || (cached && stat.size < cached.size)) {
172
- current = scanSequence(eventsPath);
170
+ // FIX: Trust the sidecar seq file if it exists and the file is non-empty.
171
+ // Only fall back to O(n) scan if sidecar is missing or file shrunk unexpectedly.
172
+ const stored = readStoredSequence(eventsPath);
173
+ if (stored !== undefined && (!cached || stat.size >= cached.size)) {
174
+ sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: stored });
175
+ return stored + 1;
173
176
  }
177
+ const current = scanSequence(eventsPath);
174
178
  sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: current });
179
+ persistSequence(eventsPath, current);
175
180
  return current + 1;
176
181
  }
177
182
 
@@ -6,6 +6,7 @@ import { redactSecrets } from "../utils/redaction.ts";
6
6
  import { logInternalError } from "../utils/internal-error.ts";
7
7
  import { atomicWriteFile } from "./atomic-write.ts";
8
8
  import { withEventLogLockSync } from "./event-log.ts";
9
+ import { DEFAULT_MAILBOX } from "../config/defaults.ts";
9
10
 
10
11
  export type MailboxDirection = "inbox" | "outbox";
11
12
  export type MailboxMessageStatus = "queued" | "delivered" | "acknowledged";
@@ -228,7 +229,7 @@ function safeReadMailboxFile(filePath: string, direction: MailboxDirection): Mai
228
229
  * primary file. Readers continue to see all messages because
229
230
  * `safeReadMailboxFile` walks both the primary file and any archives.
230
231
  */
231
- const MAILBOX_ARCHIVE_THRESHOLD_BYTES = 10 * 1024 * 1024;
232
+ const MAILBOX_ARCHIVE_THRESHOLD_BYTES = DEFAULT_MAILBOX.perFileThresholdBytes;
232
233
  function rotateMailboxFileIfNeeded(filePath: string, thresholdBytes = MAILBOX_ARCHIVE_THRESHOLD_BYTES): boolean {
233
234
  try {
234
235
  if (!fs.existsSync(filePath)) return false;
@@ -238,6 +239,8 @@ function rotateMailboxFileIfNeeded(filePath: string, thresholdBytes = MAILBOX_AR
238
239
  const archivePath = `${filePath}.${ts}.archive.jsonl`;
239
240
  fs.renameSync(filePath, archivePath);
240
241
  fs.writeFileSync(filePath, "", "utf-8");
242
+ // FIX: Prune old archives so total per-direction count stays bounded.
243
+ pruneOldMailboxArchives(filePath);
241
244
  return true;
242
245
  } catch (error) {
243
246
  logInternalError("mailbox.rotate", error, filePath);
@@ -245,6 +248,27 @@ function rotateMailboxFileIfNeeded(filePath: string, thresholdBytes = MAILBOX_AR
245
248
  }
246
249
  }
247
250
 
251
+ /**
252
+ * Keep at most `DEFAULT_MAILBOX.maxArchivesPerDirection` archive files per
253
+ * mailbox. Older archives are deleted. Prevents unbounded growth on long runs.
254
+ */
255
+ function pruneOldMailboxArchives(mailboxFilePath: string): void {
256
+ try {
257
+ const dir = path.dirname(mailboxFilePath);
258
+ const base = path.basename(mailboxFilePath);
259
+ const archives = fs
260
+ .readdirSync(dir)
261
+ .filter((f) => f.startsWith(base) && f.includes(".archive.jsonl"))
262
+ .sort(); // Chronological (ISO timestamp in filename)
263
+ const excess = archives.length - DEFAULT_MAILBOX.maxArchivesPerDirection;
264
+ for (let i = 0; i < excess; i += 1) {
265
+ fs.rmSync(path.join(dir, archives[i]), { force: true });
266
+ }
267
+ } catch (error) {
268
+ logInternalError("mailbox.prune", error, mailboxFilePath);
269
+ }
270
+ }
271
+
248
272
  export function readMailbox(manifest: TeamRunManifest, direction?: MailboxDirection, taskId?: string, kind?: MailboxMessageKind): MailboxMessage[] {
249
273
  const directions = direction ? [direction] : ["inbox", "outbox"] as const;
250
274
  return directions.flatMap((item) => safeReadMailboxFile(mailboxFile(manifest, item, taskId), item)).filter((msg) => !kind || msg.kind === kind).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
@@ -274,8 +274,9 @@ export function createSafeBash(options: SafeBashOptions = {}) {
274
274
  * These can be used in allowPatterns for specific use cases
275
275
  */
276
276
  export const COMMON_SAFE_PATTERNS = {
277
- // Safe rm with specific paths - uses simple contains check
278
- safeRm: /rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?((?![\/~])\/)?(tmp|cache|node_modules|dist|build)\//,
277
+ // FIX: Stricter regex target must be exactly tmp/, cache/, node_modules/, dist/, or build/
278
+ // (with optional ./ prefix). Rejects path traversal (./../../../other) and absolute paths.
279
+ safeRm: /rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(?:\.\/)?(?:tmp|cache|node_modules|dist|build)\/[a-zA-Z0-9._/-]+$/,
279
280
  // Safe git operations
280
281
  safeGit: /\bgit\s+(clone|pull|push|commit|add|status|diff|log|branch|checkout|merge|rebase)/,
281
282
  // Safe npm/yarn/pnpm