pi-crew 0.5.5 → 0.5.6

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/migration-v0.4-v0.5.md +19 -2
  5. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  6. package/package.json +7 -5
  7. package/src/benchmark/benchmark-runner.ts +45 -0
  8. package/src/benchmark/feedback-loop.ts +5 -0
  9. package/src/config/config.ts +10 -0
  10. package/src/config/suggestions.ts +8 -0
  11. package/src/extension/async-notifier.ts +10 -1
  12. package/src/extension/cross-extension-rpc.ts +1 -1
  13. package/src/extension/notification-router.ts +18 -0
  14. package/src/extension/register.ts +13 -17
  15. package/src/extension/registration/subagent-tools.ts +1 -1
  16. package/src/extension/team-tool/anchor.ts +201 -0
  17. package/src/extension/team-tool/api.ts +2 -1
  18. package/src/extension/team-tool/auto-summarize.ts +154 -0
  19. package/src/extension/team-tool/run.ts +37 -2
  20. package/src/extension/team-tool.ts +44 -2
  21. package/src/hooks/registry.ts +1 -3
  22. package/src/observability/event-bus.ts +13 -4
  23. package/src/observability/event-to-metric.ts +0 -2
  24. package/src/runtime/anchor-manager.ts +473 -0
  25. package/src/runtime/async-runner.ts +8 -4
  26. package/src/runtime/auto-summarize.ts +350 -0
  27. package/src/runtime/background-runner.ts +2 -1
  28. package/src/runtime/budget-tracker.ts +354 -0
  29. package/src/runtime/chain-runner.ts +507 -0
  30. package/src/runtime/child-pi.ts +1 -1
  31. package/src/runtime/crash-recovery.ts +5 -4
  32. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  33. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  34. package/src/runtime/delivery-coordinator.ts +10 -3
  35. package/src/runtime/dynamic-script-runner.ts +482 -0
  36. package/src/runtime/handoff-manager.ts +589 -0
  37. package/src/runtime/hidden-handoff.ts +424 -0
  38. package/src/runtime/live-agent-manager.ts +20 -4
  39. package/src/runtime/live-session-runtime.ts +39 -4
  40. package/src/runtime/manifest-cache.ts +2 -1
  41. package/src/runtime/model-resolver.ts +16 -4
  42. package/src/runtime/phase-tracker.ts +373 -0
  43. package/src/runtime/pipeline-runner.ts +514 -0
  44. package/src/runtime/retry-runner.ts +354 -0
  45. package/src/runtime/sandbox.ts +252 -0
  46. package/src/runtime/scheduler.ts +7 -2
  47. package/src/runtime/subagent-manager.ts +1 -1
  48. package/src/runtime/task-graph.ts +11 -1
  49. package/src/runtime/task-runner.ts +1 -1
  50. package/src/runtime/team-runner.ts +4 -3
  51. package/src/schema/team-tool-schema.ts +30 -0
  52. package/src/skills/discover-skills.ts +5 -0
  53. package/src/state/active-run-registry.ts +9 -2
  54. package/src/state/contracts.ts +9 -0
  55. package/src/state/crew-init.ts +3 -3
  56. package/src/state/decision-ledger.ts +26 -32
  57. package/src/state/event-log-rotation.ts +2 -2
  58. package/src/state/event-log.ts +9 -1
  59. package/src/state/mailbox.ts +10 -0
  60. package/src/state/run-cache.ts +18 -8
  61. package/src/tools/safe-bash-extension.ts +1 -0
  62. package/src/tools/safe-bash.ts +152 -20
  63. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  64. package/src/ui/powerbar-publisher.ts +1 -0
  65. package/src/ui/transcript-cache.ts +13 -0
  66. package/src/utils/bm25-search.ts +16 -8
  67. package/src/utils/env-filter.ts +8 -5
  68. package/src/utils/redaction.ts +169 -15
  69. package/src/utils/sse-parser.ts +10 -1
  70. package/src/worktree/cleanup.ts +6 -1
  71. package/workflows/chain.workflow.md +252 -0
  72. package/workflows/pipeline.workflow.md +27 -0
@@ -36,6 +36,7 @@ import { registerRunPromise, resolveRunPromise, rejectRunPromise } from "./run-t
36
36
  import { clearTrackedTaskUsage } from "./usage-tracker.ts";
37
37
  import { CrewCancellationError, buildSyntheticTerminalEvidence, cancellationReasonFromSignal } from "./cancellation.ts";
38
38
  import { effectivenessPolicyDecision, evaluateRunEffectiveness, formatRunEffectivenessLines } from "./effectiveness.ts";
39
+ import { logInternalError } from "../utils/internal-error.ts";
39
40
 
40
41
  export interface ExecuteTeamRunInput {
41
42
  manifest: TeamRunManifest;
@@ -279,7 +280,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
279
280
  resolveRunPromise(manifest.runId, result);
280
281
  cleanupUsage();
281
282
  // Terminate live agents for this run — agents are done when the run ends.
282
- void terminateLiveAgentsForRun(manifest.runId, "completed", appendEvent, manifest.eventsPath).catch(() => {});
283
+ void terminateLiveAgentsForRun(manifest.runId, "completed", appendEvent, manifest.eventsPath).catch((error) => logInternalError("team-runner.completed.terminate", error, `runId=${manifest.runId}`));
283
284
 
284
285
  // Emit run completion hook (100% reliable, fire-and-forget)
285
286
  crewHooks.emit({ type: "run_completed", timestamp: new Date().toISOString(), runId: manifest.runId, data: { status: result.manifest.status, taskCount: result.tasks.length } });
@@ -519,7 +520,7 @@ async function executeTeamRunCore(
519
520
  attemptId: (attempt) => `${manifest.runId}:${task.id}:attempt-${attempt}`,
520
521
  onAttemptFailed: (attempt, error, delayMs, info) => {
521
522
  lastAttemptId = info.attemptId;
522
- appendEventAsync(manifest.eventsPath, { type: "crew.task.retry_attempt", runId: manifest.runId, taskId: task.id, message: error.message, data: { attempt, attemptId: info.attemptId, delayMs }, metadata: { attemptId: info.attemptId } }).catch(() => {});
523
+ appendEventAsync(manifest.eventsPath, { type: "crew.task.retry_attempt", runId: manifest.runId, taskId: task.id, message: error.message, data: { attempt, attemptId: info.attemptId, delayMs }, metadata: { attemptId: info.attemptId } }).catch((error) => logInternalError("team-runner.retry-attempt", error, `taskId=${task.id}`));
523
524
  input.metricRegistry?.histogram("crew.task.retry_delay_ms", "Retry backoff delay, milliseconds").observe({ runId: manifest.runId, taskId: task.id }, delayMs);
524
525
  },
525
526
  onRetryGivenUp: (attempts, error, info) => {
@@ -536,7 +537,7 @@ async function executeTeamRunCore(
536
537
  const freshManifest = fresh?.manifest ?? manifest;
537
538
  const freshTasks = fresh?.tasks ?? tasks;
538
539
  const cancelledTasks = freshTasks.map((item) => item.id === task.id && (item.status === "queued" || item.status === "running") ? { ...item, status: "cancelled" as const, finishedAt: new Date().toISOString(), error: `${reason.message} (${reason.code})` } : item);
539
- appendEventAsync(freshManifest.eventsPath, { type: "task.cancelled", runId: freshManifest.runId, taskId: task.id, message: reason.message, data: { reason, phase: "retry" }, metadata: lastAttemptId ? { attemptId: lastAttemptId } : undefined }).catch(() => {});
540
+ appendEventAsync(freshManifest.eventsPath, { type: "task.cancelled", runId: freshManifest.runId, taskId: task.id, message: reason.message, data: { reason, phase: "retry" }, metadata: lastAttemptId ? { attemptId: lastAttemptId } : undefined }).catch((error) => logInternalError("team-runner.cancelled", error, `taskId=${task.id}`));
540
541
  return { manifest: updateRunStatus(freshManifest, "cancelled", reason.message), tasks: cancelledTasks };
541
542
  }
542
543
  if (lastFailed) return lastFailed;
@@ -68,6 +68,9 @@ export const TeamToolParams = Type.Object({
68
68
  Type.Literal("orchestrate"),
69
69
  Type.Literal("schedule"),
70
70
  Type.Literal("scheduled"),
71
+ Type.Literal("anchor"),
72
+ Type.Literal("auto-summarize"),
73
+ Type.Literal("auto_boomerang"),
71
74
  ],
72
75
  { description: "Team action. Defaults to 'list' when omitted." },
73
76
  ),
@@ -209,6 +212,27 @@ export const TeamToolParams = Type.Object({
209
212
  description: "Mark certain bash commands as excludeFromContext to reduce context tokens (default: false).",
210
213
  }),
211
214
  ),
215
+ // Budget tracking options
216
+ budgetTotal: Type.Optional(
217
+ Type.Number({
218
+ description: "Total token budget for the run. When set, enables budget tracking with default 80% warning and 95% abort thresholds.",
219
+ minimum: 1,
220
+ }),
221
+ ),
222
+ budgetWarning: Type.Optional(
223
+ Type.Number({
224
+ description: "Budget warning threshold as a fraction (0-1). Default: 0.8 (80%). Emits warning event when this threshold is crossed.",
225
+ minimum: 0,
226
+ maximum: 1,
227
+ }),
228
+ ),
229
+ budgetAbort: Type.Optional(
230
+ Type.Number({
231
+ description: "Budget abort threshold as a fraction (0-1). Default: 0.95 (95%). Aborts further execution when this threshold is crossed.",
232
+ minimum: 0,
233
+ maximum: 1,
234
+ }),
235
+ ),
212
236
  });
213
237
 
214
238
  export interface TeamToolParamsValue {
@@ -294,4 +318,10 @@ export interface TeamToolParamsValue {
294
318
  once?: string | number;
295
319
  /** Mark certain bash commands as excludeFromContext to reduce context tokens (default: false). */
296
320
  excludeContextBash?: boolean;
321
+ /** Total token budget for the run. When set, enables budget tracking. */
322
+ budgetTotal?: number;
323
+ /** Budget warning threshold as a fraction (0-1). Default: 0.8. */
324
+ budgetWarning?: number;
325
+ /** Budget abort threshold as a fraction (0-1). Default: 0.95. */
326
+ budgetAbort?: number;
297
327
  }
@@ -6,6 +6,9 @@ import { isSafePathId, resolveContainedPath, resolveRealContainedPath } from "..
6
6
 
7
7
  const PACKAGE_SKILLS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
8
8
 
9
+ const CACHE_TTL_MS = 30_000; // 30 seconds
10
+ let cache: { skills: SkillDescriptor[]; cachedAt: number; cwd: string } | null = null;
11
+
9
12
  export interface SkillDescriptor {
10
13
  name: string;
11
14
  description: string;
@@ -28,6 +31,7 @@ function frontmatterDescription(content: string): string | undefined {
28
31
  }
29
32
 
30
33
  export function discoverSkills(cwd: string): SkillDescriptor[] {
34
+ if (cache && cache.cwd === cwd && Date.now() - cache.cachedAt < CACHE_TTL_MS) return cache.skills;
31
35
  const results: SkillDescriptor[] = [];
32
36
  for (const dir of listSkillDirs(cwd)) {
33
37
  if (!fs.existsSync(dir.root)) continue;
@@ -63,5 +67,6 @@ export function discoverSkills(cwd: string): SkillDescriptor[] {
63
67
  logInternalError("discoverSkills.readdir", error, `root=${dir.root}`);
64
68
  }
65
69
  }
70
+ cache = { skills: results, cachedAt: Date.now(), cwd };
66
71
  return results;
67
72
  }
@@ -10,6 +10,9 @@ import { sharedScanCache } from "../utils/scan-cache.ts";
10
10
  import { sleepSync } from "../utils/sleep.ts";
11
11
  import { logInternalError } from "../utils/internal-error.ts";
12
12
 
13
+ /** Magic bytes prefix for binary registry to prevent deserialization of hostile files. */
14
+ const BINARY_MAGIC = Buffer.from("PICREW2BIN", "utf-8");
15
+
13
16
  export interface ActiveRunRegistryEntry {
14
17
  runId: string;
15
18
  cwd: string;
@@ -111,7 +114,11 @@ export function readActiveRunRegistry(maxEntries = DEFAULT_CACHE.manifestMaxEntr
111
114
  // corrupt; this lets a 2-release migration co-exist with old readers.
112
115
  try {
113
116
  const buf = fs.readFileSync(registryBinaryPath());
114
- parsed = deserialize(buf);
117
+ // Security: verify magic bytes before deserializing to prevent RCE from hostile files
118
+ if (buf.length < BINARY_MAGIC.length || !buf.slice(0, BINARY_MAGIC.length).equals(BINARY_MAGIC)) {
119
+ throw new Error("Invalid binary registry: missing magic bytes");
120
+ }
121
+ parsed = deserialize(buf.slice(BINARY_MAGIC.length));
115
122
  } catch {
116
123
  try {
117
124
  parsed = JSON.parse(fs.readFileSync(registryPath(), "utf-8"));
@@ -135,7 +142,7 @@ function writeEntries(entries: ActiveRunRegistryEntry[]): void {
135
142
  atomicWriteJson(registryPath(), trimmed);
136
143
  try {
137
144
  const tempBin = `${registryBinaryPath()}.${process.pid}.${Date.now()}.tmp`;
138
- fs.writeFileSync(tempBin, serialize(trimmed));
145
+ fs.writeFileSync(tempBin, Buffer.concat([BINARY_MAGIC, serialize(trimmed)]));
139
146
  fs.renameSync(tempBin, registryBinaryPath());
140
147
  } catch (error) {
141
148
  logInternalError("active-run-registry.binary-write", error);
@@ -67,6 +67,15 @@ export const TEAM_EVENT_TYPES = [
67
67
  "task.resumed",
68
68
  "task.retried",
69
69
  "supervisor.contact",
70
+ // Budget tracking events
71
+ "budget.initialized",
72
+ "budget.warning",
73
+ "budget.exhausted",
74
+ // Phase tracking events
75
+ "phase.started",
76
+ "phase.completed",
77
+ "phase.skipped",
78
+ "phase.failed",
70
79
  ] as const;
71
80
  export type TeamEventType = typeof TEAM_EVENT_TYPES[number];
72
81
 
@@ -108,9 +108,9 @@ export async function ensureCrewDirectory(cwd: string): Promise<void> {
108
108
  ];
109
109
 
110
110
  for (const dir of dirs) {
111
- if (!fs.existsSync(dir)) {
112
- fs.mkdirSync(dir, { recursive: true });
113
- }
111
+ // Use mkdirSync directly with recursive:true to avoid TOCTOU race.
112
+ // This is atomic and doesn't require existsSync check.
113
+ fs.mkdirSync(dir, { recursive: true });
114
114
  }
115
115
 
116
116
  // 2. Create .gitkeep placeholders in directories that should be tracked
@@ -1,5 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
2
2
  import { dirname } from "path";
3
+ import { atomicWriteFile } from "./atomic-write.ts";
3
4
 
4
5
  export interface CoherenceMark {
5
6
  matchesPrior: boolean;
@@ -21,9 +22,12 @@ export interface RolloutEntry {
21
22
 
22
23
  /**
23
24
  * Get the ledger file path for a given run ID.
25
+ * SECURITY: Accept stateRoot param to use it for path computation
26
+ * instead of hardcoded path, ensuring stateRoot containment.
24
27
  */
25
- function getLedgerPath(runId: string): string {
26
- return `.crew/state/runs/${runId}/decision-ledger.jsonl`;
28
+ function getLedgerPath(runId: string, stateRoot?: string): string {
29
+ const base = stateRoot ?? `.crew/state/runs/${runId}`;
30
+ return `${base}/decision-ledger.jsonl`;
27
31
  }
28
32
 
29
33
  /**
@@ -44,19 +48,19 @@ function computeCoherence(entry: RolloutEntry, ledger: RolloutEntry[]): Coherenc
44
48
  entry.decisionMark === previousEntry.decisionMark ||
45
49
  Boolean(entry.priorWinner && entry.topCandidates.includes(entry.priorWinner));
46
50
 
47
- // Check last 3 entries for recursive pattern
48
- const recentEntries = ledger.slice(-3);
51
+ // Check last 10 entries for recursive pattern
52
+ const recentEntries = ledger.slice(-10);
49
53
  const recentDecisions = recentEntries.map((e) => e.decisionMark);
50
54
  const currentDecision = entry.decisionMark;
51
55
 
52
56
  const recursiveMatches = recentDecisions.filter((d) => d === currentDecision).length;
53
- const matchesRecursive = recursiveMatches >= 2;
57
+ const matchesRecursive = recursiveMatches >= Math.ceil(recentDecisions.length / 2); // At least half match
54
58
 
55
59
  const promotionAllowed = matchesPrior || matchesRecursive;
56
60
 
57
61
  let reason: string;
58
62
  if (matchesPrior && matchesRecursive) {
59
- reason = `Matches prior winner and recursive pattern (${recursiveMatches}/3 recent decisions)`;
63
+ reason = `Matches prior winner and recursive pattern (${recursiveMatches}/${recentDecisions.length} recent decisions)`;
60
64
  } else if (matchesPrior) {
61
65
  reason = `Matches prior winner decision`;
62
66
  } else if (matchesRecursive) {
@@ -94,17 +98,17 @@ export function initLedger(runId: string): void {
94
98
  /**
95
99
  * Append a new entry to the decision ledger.
96
100
  * Automatically computes and adds coherence marks.
101
+ * FIX: Uses atomic write to prevent partial writes on crash.
97
102
  */
98
103
  export function appendEntry(runId: string, entry: RolloutEntry): RolloutEntry {
99
- const ledgerPath = getLedgerPath(runId);
100
-
101
104
  // Ensure directory exists
105
+ const ledgerPath = getLedgerPath(runId);
102
106
  const dir = dirname(ledgerPath);
103
107
  if (!existsSync(dir)) {
104
108
  mkdirSync(dir, { recursive: true });
105
109
  }
106
110
 
107
- // Get existing entries to compute coherence
111
+ // Get existing entries to compute coherence (and use same result for write)
108
112
  const ledger = getLedger(runId);
109
113
 
110
114
  // Compute coherence
@@ -114,9 +118,11 @@ export function appendEntry(runId: string, entry: RolloutEntry): RolloutEntry {
114
118
  coherenceMark,
115
119
  };
116
120
 
117
- // Append to JSONL file
121
+ // Append to JSONL file using atomic write to prevent corruption
122
+ // Use the already-loaded ledger content (no double-read)
118
123
  const line = JSON.stringify(entryWithCoherence) + "\n";
119
- writeFileSync(ledgerPath, line, { flag: "a", encoding: "utf-8" });
124
+ const existingContent = ledger.length > 0 ? ledger.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
125
+ atomicWriteFile(ledgerPath, existingContent + line);
120
126
  return entryWithCoherence;
121
127
  }
122
128
 
@@ -233,7 +239,7 @@ function overrideLastEntry(runId: string, coherenceMark: import("./types.js").Co
233
239
  ledger[lastIndex] = { ...ledger[lastIndex], coherenceMark };
234
240
  // Rewrite entire ledger to preserve all entries
235
241
  const ledgerPath = getLedgerPath(runId);
236
- writeFileSync(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
242
+ atomicWriteFile(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n");
237
243
  return ledger[lastIndex];
238
244
  }
239
245
 
@@ -270,28 +276,22 @@ export function promoteCandidate(runId: string, candidate: string): RolloutEntry
270
276
  coherenceMark,
271
277
  };
272
278
 
273
- // Update last entry in memory if there are existing entries
274
- if (ledger.length > 0) {
275
- const lastIndex = ledger.length - 1;
276
- ledger[lastIndex] = entry;
277
- } else {
278
- // No existing entries - just write this one
279
- ledger.push(entry);
280
- }
279
+ // Always push new entry (append-only pattern)
280
+ ledger.push(entry);
281
281
 
282
- // Rewrite entire ledger to preserve all entries
282
+ // Rewrite entire ledger atomically to preserve all entries
283
283
  const ledgerPath = getLedgerPath(runId);
284
284
  const dir = dirname(ledgerPath);
285
285
  if (!existsSync(dir)) {
286
286
  mkdirSync(dir, { recursive: true });
287
287
  }
288
- writeFileSync(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
288
+ atomicWriteFile(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n");
289
289
 
290
290
  return entry;
291
291
  }
292
292
 
293
293
  /**
294
- * Decay a candidate by marking it as decayed with proper coherence.
294
+ * Decay a candidate by marking it as accepted with proper coherence.
295
295
  */
296
296
  export function decayCandidate(runId: string, candidate: string): RolloutEntry {
297
297
  const latestDecision = getLatestDecision(runId);
@@ -323,14 +323,8 @@ export function decayCandidate(runId: string, candidate: string): RolloutEntry {
323
323
  coherenceMark,
324
324
  };
325
325
 
326
- // Update last entry in memory if there are existing entries
327
- if (ledger.length > 0) {
328
- const lastIndex = ledger.length - 1;
329
- ledger[lastIndex] = entry;
330
- } else {
331
- // No existing entries - just write this one
332
- ledger.push(entry);
333
- }
326
+ // Always push new entry (append-only pattern)
327
+ ledger.push(entry);
334
328
 
335
329
  // Rewrite entire ledger to preserve all entries
336
330
  const ledgerPath = getLedgerPath(runId);
@@ -338,7 +332,7 @@ export function decayCandidate(runId: string, candidate: string): RolloutEntry {
338
332
  if (!existsSync(dir)) {
339
333
  mkdirSync(dir, { recursive: true });
340
334
  }
341
- writeFileSync(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
335
+ atomicWriteFile(ledgerPath, ledger.map((e) => JSON.stringify(e)).join("\n") + "\n");
342
336
 
343
337
  return entry;
344
338
  }
@@ -209,9 +209,9 @@ export function getEventLogStats(eventsPath: string): EventLogStats | undefined
209
209
  if (newlineCount === 0) firstLineBytes = offset + i + 1;
210
210
  newlineCount++;
211
211
  }
212
- }
213
- offset += bytesRead;
214
212
  }
213
+ offset += bytesRead;
214
+ }
215
215
  } finally {
216
216
  fs.closeSync(scanFd);
217
217
  }
@@ -293,7 +293,7 @@ export async function appendEventAsync(eventsPath: string, event: AppendTeamEven
293
293
  }
294
294
  return fullEvent;
295
295
  });
296
- asyncQueues.set(queueKey, next.catch(() => {}));
296
+ asyncQueues.set(queueKey, next.catch((error) => { logInternalError("event-log.async-queue", error, eventsPath); asyncQueues.delete(queueKey); }));
297
297
  return next;
298
298
  }
299
299
 
@@ -425,8 +425,16 @@ function flushOneEventLogBuffer(eventsPath: string): void {
425
425
  bufferedQueues.delete(eventsPath);
426
426
  const timer = bufferedTimers.get(eventsPath);
427
427
  if (timer) clearTimeout(timer);
428
+ // MEDIUM-13: Delete timer entry only after successful flush (in finally block)
428
429
  bufferedTimers.delete(eventsPath);
429
430
  if (!queue || queue.length === 0) return;
431
+
432
+ // HIGH-10: Clean up queue if it exceeds limit to prevent unbounded growth
433
+ if (queue.length > 1000) {
434
+ // Keep only the last 500 entries
435
+ queue.splice(0, queue.length - 500);
436
+ }
437
+
430
438
  try {
431
439
  withEventLogLockSync(eventsPath, () => {
432
440
  for (const item of queue) {
@@ -289,6 +289,16 @@ export function readDeliveryState(manifest: TeamRunManifest): MailboxDeliverySta
289
289
 
290
290
  function writeDeliveryState(manifest: TeamRunManifest, state: MailboxDeliveryState): void {
291
291
  ensureRunMailbox(manifest);
292
+ // Prune oldest entries if capped
293
+ const MAX_DELIVERY_MESSAGES = 10000;
294
+ if (Object.keys(state.messages).length > MAX_DELIVERY_MESSAGES) {
295
+ const sorted = Object.entries(state.messages).sort(([, a], [, b]) => {
296
+ const order = { queued: 0, delivered: 1, acknowledged: 2 };
297
+ return (order[a] ?? 3) - (order[b] ?? 3);
298
+ });
299
+ const trimmed = sorted.slice(0, MAX_DELIVERY_MESSAGES);
300
+ state.messages = Object.fromEntries(trimmed);
301
+ }
292
302
  atomicWriteFile(deliveryFile(manifest, true), `${JSON.stringify(redactSecrets(state), null, 2)}\n`);
293
303
  }
294
304
 
@@ -3,6 +3,8 @@ import * as path from "node:path";
3
3
  import * as crypto from "node:crypto";
4
4
  import { projectCrewRoot } from "../utils/paths.ts";
5
5
  import type { TeamTaskState } from "./types.ts";
6
+ import { atomicWriteFile } from "./atomic-write.ts";
7
+ import { withFileLockSync } from "./locks.ts";
6
8
 
7
9
  const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
8
10
 
@@ -31,6 +33,7 @@ export function computeRunCacheKey(goal: string, team: string, workflow: string,
31
33
  .update(normalized)
32
34
  .update(team)
33
35
  .update(workflow)
36
+ .update(_cwd)
34
37
  .digest("hex")
35
38
  .slice(0, 16);
36
39
  }
@@ -61,12 +64,15 @@ export function getCachedRun(cwd: string, cacheKey: string): CacheEntry | null {
61
64
  const entry = JSON.parse(fs.readFileSync(entryPath, "utf-8")) as CacheEntry;
62
65
 
63
66
  if (Date.now() > entry.expiresAt) {
64
- // Remove expired entry
65
- try {
66
- fs.unlinkSync(entryPath);
67
- } catch { /* ignore */ }
68
- delete index[cacheKey];
69
- fs.writeFileSync(indexPath, JSON.stringify(index), "utf-8");
67
+ // Remove expired entry — use lock + atomic write to prevent index corruption
68
+ withFileLockSync(indexPath, () => {
69
+ try {
70
+ fs.unlinkSync(entryPath);
71
+ } catch { /* ignore */ }
72
+ const updatedIndex = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
73
+ delete updatedIndex[cacheKey];
74
+ atomicWriteFile(indexPath, JSON.stringify(updatedIndex));
75
+ });
70
76
  return null;
71
77
  }
72
78
 
@@ -109,14 +115,18 @@ export function saveRunToCache(
109
115
  const entryPath = path.join(dir, `${cacheKey}.json`);
110
116
  fs.writeFileSync(entryPath, JSON.stringify(entry), "utf-8");
111
117
 
112
- // Update index
118
+ // Update index with atomic write: write to temp file then rename
113
119
  const indexPath = path.join(dir, "index.json");
114
120
  const index: CacheIndex = fs.existsSync(indexPath)
115
121
  ? JSON.parse(fs.readFileSync(indexPath, "utf-8"))
116
122
  : {};
117
123
 
118
124
  index[cacheKey] = entryPath;
119
- fs.writeFileSync(indexPath, JSON.stringify(index), "utf-8");
125
+
126
+ // Atomic write: write to temp file first, then rename
127
+ const tempPath = path.join(dir, "index.json.tmp");
128
+ fs.writeFileSync(tempPath, JSON.stringify(index), "utf-8");
129
+ fs.renameSync(tempPath, indexPath);
120
130
  }
121
131
 
122
132
  /**
@@ -68,6 +68,7 @@ export default function safeBashExtension(pi: ExtensionAPI): void {
68
68
  "Execute a bash command safely. Blocks dangerous commands like `rm -rf /`, `sudo`, `curl | sh`, etc.",
69
69
  parameters: Type.Object({
70
70
  command: Type.String({ description: "Bash command to execute" }),
71
+ /** Timeout in seconds (optional). Default: no timeout. If exceeded, the command is killed. */
71
72
  timeout: Type.Optional(
72
73
  Type.Number({ description: "Timeout in seconds (optional)" }),
73
74
  ),
@@ -1,50 +1,144 @@
1
1
  /**
2
2
  * Safe Bash Tool for pi-crew
3
3
  * Wraps bash with dangerous command blocking
4
+ * Uses linear-time scanning to prevent ReDoS attacks
4
5
  */
5
6
 
6
7
  import { Type } from "@sinclair/typebox";
7
8
 
8
- // Dangerous command patterns to block
9
+ // Backward-compatible pattern array (kept for getPatterns API)
10
+ // IMPORTANT: Line 8 (rm pattern with nested quantifiers) has been replaced
11
+ // with linear-time checking in isDangerous() to prevent ReDoS attacks.
9
12
  const DANGEROUS_PATTERNS = [
10
- // rm -rf / or rm -rf ~ (catastrophic root/home deletion)
11
- /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s*)+(\/|~)(\s*$)/,
12
- /\brm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s*)+(\/|~)($|\s)/,
13
- // Privilege escalation
13
+ // NOTE: rm patterns handled by matchesDangerousRm() for linear-time safety
14
14
  /\bsudo\b/,
15
15
  /\bsu\s+root\b/,
16
- // Filesystem destruction
17
16
  /\bmkfs\b/,
18
17
  /\bdd\s+if=/,
19
- // Fork bomb
20
18
  /^:\s*\(\s*\)\s*\{.*\|.*&.*\}\s*;.*$/,
21
- // Device writing
22
19
  />\s*\/dev\/[sh]d[a-z]/,
23
20
  /\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
24
21
  /\bchown\s+(-[a-zA-Z]+\s+)?root/,
25
- // Pipe to shell (download and execute)
26
22
  /\bcurl\s.*\|\s*(ba)?sh/i,
27
23
  /\bwget\s.*\|\s*(ba)?sh/i,
28
- // System shutdown/reboot
29
24
  /\bshutdown\b/,
30
25
  /\breboot\b/,
31
26
  /\binit\s+0\b/,
32
- // Kill critical processes
33
27
  /\bkill\s+-9\s+1\b/,
34
28
  /\bkillall\b/,
35
- // Encoded commands
36
29
  /\|\s*base64\s+-d/,
37
30
  /\|\s*python.*-c/,
38
31
  /\|\s*perl.*-e/,
39
32
  /\|\s*ruby.*-e/,
40
- // Network to shell
41
- /\bbash\s+-i\s+>\s*\&/,
33
+ /\bbash\s+-i\s*>\s*\&/,
42
34
  /\bexec\s+.*bash/,
43
- // /etc/passwd manipulation
44
35
  /\becho\s+.*>\s*\/etc\/passwd/,
45
36
  /\bcat\s+.*>\s*\/etc\/passwd/,
46
37
  ];
47
38
 
39
+ /**
40
+ * Linear-time check if command contains a dangerous rm pattern like "rm -rf /" or "rm -rf ~"
41
+ * Replaces O(n²) regex backtracking with O(n) string scanning
42
+ */
43
+ function matchesDangerousRm(command: string): boolean {
44
+ let pos = 0;
45
+ const len = command.length;
46
+ // Find "rm" at word boundary
47
+ while (pos < len) {
48
+ const rmIdx = command.indexOf("rm", pos);
49
+ if (rmIdx === -1) return false;
50
+ // Check word boundary before "rm"
51
+ if (rmIdx > 0 && /\w/.test(command[rmIdx - 1])) {
52
+ pos = rmIdx + 1;
53
+ continue;
54
+ }
55
+ // Must be followed by whitespace
56
+ const afterRm = rmIdx + 2;
57
+ if (afterRm >= len || /\s/.test(command[afterRm])) {
58
+ // Found "rm " - now check for -rf flags followed by / or ~
59
+ let p = afterRm + 1;
60
+ while (p < len) {
61
+ // Skip whitespace
62
+ while (p < len && /\s/.test(command[p])) p++;
63
+ if (p >= len) break;
64
+ // Check for flag
65
+ if (command[p] !== "-") break;
66
+ p++;
67
+ let hasR = false, hasF = false;
68
+ while (p < len && /[a-zA-Z]/.test(command[p])) {
69
+ if (command[p] === "r" || command[p] === "R") hasR = true;
70
+ if (command[p] === "f" || command[p] === "F") hasF = true;
71
+ p++;
72
+ }
73
+ if (!hasR && !hasF) break; // Flag must have r or f
74
+ // Skip whitespace after flag
75
+ while (p < len && /\s/.test(command[p])) p++;
76
+ }
77
+ // Now check if followed by / or ~ (end or whitespace)
78
+ if (p < len && (command[p] === "/" || command[p] === "~")) {
79
+ const afterSlash = p + 1;
80
+ if (afterSlash >= len || /\s/.test(command[afterSlash]) || command[afterSlash] === ";") {
81
+ return true; // Dangerous!
82
+ }
83
+ }
84
+ }
85
+ pos = rmIdx + 1;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Linear-time check for fork bomb pattern: :() { ... | ... & ... } ; ...
92
+ */
93
+ function matchesForkBomb(command: string): boolean {
94
+ // Must start with :
95
+ const trimmed = command.trimStart();
96
+ if (!trimmed.startsWith(":")) return false;
97
+ // Find () after :
98
+ const parenIdx = trimmed.indexOf("()");
99
+ if (parenIdx === -1 || parenIdx > 10) return false; // : must be close to ()
100
+ // Find { after ()
101
+ const braceIdx = trimmed.indexOf("{", parenIdx);
102
+ if (braceIdx === -1 || braceIdx > parenIdx + 5) return false;
103
+ // Find } closing brace
104
+ const closeBrace = trimmed.indexOf("}", braceIdx);
105
+ if (closeBrace === -1) return false;
106
+ // Check content between braces for | and &
107
+ const content = trimmed.slice(braceIdx + 1, closeBrace);
108
+ if (content.includes("|") && content.includes("&")) return true;
109
+ return false;
110
+ }
111
+
112
+ /**
113
+ * Check for encoded command patterns (pipe to shell)
114
+ */
115
+ function matchesEncodedPipe(command: string): boolean {
116
+ const lower = command.toLowerCase();
117
+ const pipeIdx = lower.indexOf("|");
118
+ if (pipeIdx === -1) return false;
119
+ const afterPipe = lower.slice(pipeIdx + 1).trimStart();
120
+ if (afterPipe.startsWith("base64") || afterPipe.startsWith("python") || afterPipe.startsWith("perl") || afterPipe.startsWith("ruby")) {
121
+ // Check if followed by -d or -c or -e
122
+ const rest = afterPipe.slice(6).trimStart();
123
+ if (rest.startsWith("-d") || rest.startsWith("-c") || rest.startsWith("-e")) return true;
124
+ }
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * Check if command contains a specific dangerous substring
130
+ */
131
+ function containsDangerous(command: string, pattern: string): boolean {
132
+ return command.indexOf(pattern) !== -1;
133
+ }
134
+
135
+ /**
136
+ * Check if command starts with dangerous prefix
137
+ */
138
+ function startsWithDangerous(command: string, pattern: string): boolean {
139
+ return command.trimStart().startsWith(pattern);
140
+ }
141
+
48
142
  export interface SafeBashOptions {
49
143
  /** Enable/disable safe mode. Default: true */
50
144
  enabled?: boolean;
@@ -75,9 +169,47 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
75
169
  }
76
170
  }
77
171
 
78
- // Check dangerous patterns
79
- const allPatterns = [...DANGEROUS_PATTERNS, ...additionalPatterns];
80
- for (const pattern of allPatterns) {
172
+ // Use linear-time scanning functions for critical patterns
173
+ if (matchesDangerousRm(normalized)) {
174
+ return "Command blocked by safe_bash: dangerous rm pattern detected";
175
+ }
176
+ if (matchesForkBomb(normalized)) {
177
+ return "Command blocked by safe_bash: fork bomb pattern detected";
178
+ }
179
+ if (matchesEncodedPipe(normalized)) {
180
+ return "Command blocked by safe_bash: encoded pipe to shell detected";
181
+ }
182
+
183
+ // Check remaining patterns using regex (these are safe from ReDoS)
184
+ for (const pattern of DANGEROUS_PATTERNS) {
185
+ if (pattern.test(normalized)) {
186
+ return `Command blocked by safe_bash: matches dangerous pattern \`${pattern}\``;
187
+ }
188
+ }
189
+
190
+ // Additional shell injection checks using regex for non-critical patterns
191
+ // Block command substitution $(...)
192
+ if (/\$\([^)]*\)/.test(command)) {
193
+ return "Command blocked by safe_bash: command substitution $(...) is not allowed";
194
+ }
195
+ // Block backtick substitution
196
+ const backtickRe = /`[^`]*`/;
197
+ if (backtickRe.test(command)) {
198
+ return "Command blocked by safe_bash: backtick substitution is not allowed";
199
+ }
200
+ // Block here-docs <<
201
+ if (/<<\s*['"]?[\w-]+['"]?/.test(command) || /\$<<\s*['"]?[\w-]+['"]?/.test(command)) {
202
+ return "Command blocked by safe_bash: here-doc is not allowed";
203
+ }
204
+ // Block ${...} variable expansion containing shell metacharacters (pipes, redirects, &&/||)
205
+ const varExpRe = /\$\{([^}]*)\}/;
206
+ const varMatch = command.match(varExpRe);
207
+ if (varMatch && /[|&;<>]/.test(varMatch[1])) {
208
+ return "Command blocked by safe_bash: variable expansion with shell metacharacters is not allowed";
209
+ }
210
+
211
+ // Check additional patterns (user-provided regex)
212
+ for (const pattern of additionalPatterns) {
81
213
  if (pattern.test(normalized)) {
82
214
  return `Command blocked by safe_bash: matches dangerous pattern \`${pattern}\``;
83
215
  }
@@ -142,8 +274,8 @@ export function createSafeBash(options: SafeBashOptions = {}) {
142
274
  * These can be used in allowPatterns for specific use cases
143
275
  */
144
276
  export const COMMON_SAFE_PATTERNS = {
145
- // Safe rm with specific paths
146
- safeRm: /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?((?![\/~])\/)?(tmp|cache|node_modules|dist|build)\//,
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)\//,
147
279
  // Safe git operations
148
280
  safeGit: /\bgit\s+(clone|pull|push|commit|add|status|diff|log|branch|checkout|merge|rebase)/,
149
281
  // Safe npm/yarn/pnpm