pi-crew 0.6.0 → 0.6.3

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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. package/test-integration-check.ts +4 -0
@@ -39,7 +39,8 @@ const DANGEROUS_PATTERNS = [
39
39
 
40
40
  /**
41
41
  * Linear-time check if command contains a dangerous rm pattern like "rm -rf /" or "rm -rf ~"
42
- * Replaces O(n²) regex backtracking with O(n) string scanning
42
+ * Replaces O(n²) regex backtracking with O(n) string scanning.
43
+ * Expanded to also block: rm -rf /etc/*, rm --recursive --force /, rm -rf ~/.ssh, etc.
43
44
  */
44
45
  function matchesDangerousRm(command: string): boolean {
45
46
  let pos = 0;
@@ -56,32 +57,75 @@ function matchesDangerousRm(command: string): boolean {
56
57
  // Must be followed by whitespace
57
58
  const afterRm = rmIdx + 2;
58
59
  if (afterRm >= len || /\s/.test(command[afterRm])) {
59
- // Found "rm " - now check for -rf flags followed by / or ~
60
+ // Found "rm " - now check for recursive/force flags
60
61
  let p = afterRm + 1;
62
+ let hasR = false;
63
+ let hasF = false;
61
64
  while (p < len) {
62
65
  // Skip whitespace
63
66
  while (p < len && /\s/.test(command[p])) p++;
64
67
  if (p >= len) break;
65
- // Check for flag
66
- if (command[p] !== "-") break;
67
- p++;
68
- let hasR = false, hasF = false;
69
- while (p < len && /[a-zA-Z]/.test(command[p])) {
70
- if (command[p] === "r" || command[p] === "R") hasR = true;
71
- if (command[p] === "f" || command[p] === "F") hasF = true;
68
+ // Check for short flags (-r, -f, -rf, -R, -F, etc.)
69
+ if (command[p] === "-" && p + 1 < len && /[a-zA-Z]/.test(command[p + 1]) && command[p + 1] !== "-") {
72
70
  p++;
71
+ while (p < len && /[a-zA-Z]/.test(command[p])) {
72
+ if (command[p] === "r" || command[p] === "R") hasR = true;
73
+ if (command[p] === "f" || command[p] === "F") hasF = true;
74
+ p++;
75
+ }
76
+ // Skip whitespace after flag
77
+ while (p < len && /\s/.test(command[p])) p++;
78
+ continue;
73
79
  }
74
- if (!hasR && !hasF) break; // Flag must have r or f
75
- // Skip whitespace after flag
76
- while (p < len && /\s/.test(command[p])) p++;
77
- }
78
- // Now check if followed by / or ~ (end or whitespace)
79
- if (p < len && (command[p] === "/" || command[p] === "~")) {
80
- const afterSlash = p + 1;
81
- if (afterSlash >= len || /\s/.test(command[afterSlash]) || command[afterSlash] === ";") {
82
- return true; // Dangerous!
80
+ // Check for long flags (--recursive, --force)
81
+ if (command[p] === "-" && p + 1 < len && command[p + 1] === "-") {
82
+ p += 2;
83
+ const flagStart = p;
84
+ while (p < len && /[a-zA-Z]/.test(command[p])) p++;
85
+ const flagName = command.slice(flagStart, p);
86
+ if (flagName === "recursive") hasR = true;
87
+ if (flagName === "force") hasF = true;
88
+ // Skip whitespace after flag
89
+ while (p < len && /\s/.test(command[p])) p++;
90
+ continue;
83
91
  }
92
+ // Not a flag — stop parsing flags
93
+ break;
94
+ }
95
+ // Must have both -r and -f (or equivalents) to be dangerous
96
+ if (!hasR || !hasF) {
97
+ pos = rmIdx + 1;
98
+ continue;
99
+ }
100
+ // Now check if followed by dangerous targets
101
+ if (p >= len) {
102
+ pos = rmIdx + 1;
103
+ continue;
104
+ }
105
+ // Block: ~ (home directory references)
106
+ const charAtP = command[p];
107
+ if (charAtP === "~") return true; // Home directory reference
108
+ // Block: / (root or dangerous system paths)
109
+ if (charAtP === "/") {
110
+ // Exact root '/' with nothing after
111
+ if (p + 1 >= len || /\s/.test(command[p + 1]) || command[p + 1] === ";") return true;
112
+ // Block dangerous system paths
113
+ const rest = command.slice(p);
114
+ if (/^\/etc[\/\s;]/.test(rest) || rest === "/etc") return true;
115
+ if ((/^\/var\/(?!tmp)/.test(rest)) || rest === "/var") return true;
116
+ if (/^\/usr[\/\s;]/.test(rest) || rest === "/usr") return true;
117
+ if (/^\/boot[\/\s;]/.test(rest) || rest === "/boot") return true;
118
+ if (/^\/sys[\/\s;]/.test(rest) || rest === "/sys") return true;
119
+ if (/^\/proc[\/\s;]/.test(rest) || rest === "/proc") return true;
120
+ if (/^\/dev[\/\s;]/.test(rest) || rest === "/dev") return true;
121
+ if (/^\/root[\/\s;]/.test(rest) || rest === "/root") return true;
122
+ if (/^\/home[\/\s;]/.test(rest) || rest === "/home") return true;
123
+ // /tmp/ and other non-system absolute paths are allowed
84
124
  }
125
+ // Check for sensitive relative paths: .ssh, .gnupg
126
+ const rest = command.slice(p);
127
+ if (/^\.ssh[\/\\\s;]/.test(rest)) return true;
128
+ if (/^\.gnupg[\/\\\s;]/.test(rest)) return true;
85
129
  }
86
130
  pos = rmIdx + 1;
87
131
  }
@@ -172,8 +216,13 @@ export function isDangerous(command: string, options: SafeBashOptions = {}): str
172
216
  }
173
217
  }
174
218
 
175
- // Normalize: remove line continuations, collapse whitespace
176
- const normalized = command.replace(/\\\n/g, " ").replace(/\s+/g, " ").trim();
219
+ // Normalize: strip ANSI escapes and control chars, remove line continuations, collapse whitespace
220
+ const normalized = command
221
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // strip ANSI escapes
222
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') // strip control chars
223
+ .replace(/\\\n/g, " ")
224
+ .replace(/\s+/g, " ")
225
+ .trim();
177
226
 
178
227
  // Check allow patterns first (overrides)
179
228
  for (const pattern of allowPatterns) {
@@ -11,24 +11,29 @@ export type {
11
11
  // Using AgentEndEvent and AgentStartEvent instead
12
12
 
13
13
  // Type guards for pi-crew usage
14
- export function isToolEvent(event: AgentSessionEvent): boolean {
14
+ /** @internal */
15
+ function isToolEvent(event: AgentSessionEvent): boolean {
15
16
  return event.type === "tool_execution_start" ||
16
17
  event.type === "tool_execution_update" ||
17
18
  event.type === "tool_execution_end";
18
19
  }
19
20
 
20
- export function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
21
+ /** @internal */
22
+ function isAgentLifecycleEvent(event: AgentSessionEvent): boolean {
21
23
  return event.type === "agent_start" || event.type === "agent_end";
22
24
  }
23
25
 
24
- export function isCompactionEvent(event: AgentSessionEvent): boolean {
26
+ /** @internal */
27
+ function isCompactionEvent(event: AgentSessionEvent): boolean {
25
28
  return event.type === "compaction_start" || event.type === "compaction_end";
26
29
  }
27
30
 
28
- export function isRetryEvent(event: AgentSessionEvent): boolean {
31
+ /** @internal */
32
+ function isRetryEvent(event: AgentSessionEvent): boolean {
29
33
  return event.type === "auto_retry_start" || event.type === "auto_retry_end";
30
34
  }
31
35
 
32
- export function isQueueEvent(event: AgentSessionEvent): boolean {
36
+ /** @internal */
37
+ function isQueueEvent(event: AgentSessionEvent): boolean {
33
38
  return event.type === "queue_update";
34
39
  }
@@ -21,7 +21,8 @@ export const DASHBOARD_KEYS = {
21
21
  notification: { dismissAll: ["H"] },
22
22
  } as const;
23
23
 
24
- export const KEY_RESERVED = new Set<string>([
24
+ /** @internal */
25
+ const KEY_RESERVED = new Set<string>([
25
26
  ...DASHBOARD_KEYS.close,
26
27
  ...DASHBOARD_KEYS.select,
27
28
  ...Object.values(DASHBOARD_KEYS.root).flat(),
@@ -109,7 +109,7 @@ export class LiveRunSidebar {
109
109
 
110
110
  render(width: number): string[] {
111
111
  const w = Math.max(36, width);
112
- const loaded = loadRunManifestById(this.cwd, this.runId);
112
+ const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
113
113
  if (!loaded) {
114
114
  return renderLines(
115
115
  [
package/src/ui/loaders.ts CHANGED
@@ -131,6 +131,10 @@ export class CountdownTimer {
131
131
  this.emitExpire();
132
132
  }
133
133
  }, 1000);
134
+ // Defense-in-depth: never let the countdown timer keep the event loop
135
+ // alive. If dispose() is missed (e.g. UI unmount race), the timer must
136
+ // not block process exit.
137
+ if (typeof this.timer.unref === "function") this.timer.unref();
134
138
  }
135
139
 
136
140
  private emitExpire(): void {
@@ -15,7 +15,7 @@ export class AgentPickerOverlay {
15
15
  private selected = 0;
16
16
 
17
17
  constructor(opts: { cwd: string; runId: string; done: (selection: AgentPickerSelection | undefined) => void; theme?: unknown }) {
18
- const loaded = loadRunManifestById(opts.cwd, opts.runId);
18
+ const loaded = loadRunManifestById(opts.cwd, opts.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
19
19
  this.agents = loaded ? readCrewAgents(loaded.manifest) : [];
20
20
  this.done = opts.done;
21
21
  this.theme = asCrewTheme(opts.theme ?? {});
@@ -32,7 +32,7 @@ export class MailboxDetailOverlay {
32
32
  }
33
33
 
34
34
  private refresh(): void {
35
- const loaded = loadRunManifestById(this.cwd, this.runId);
35
+ const loaded = loadRunManifestById(this.cwd, this.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
36
36
  if (!loaded) return;
37
37
  // Track task count changes to trigger re-render
38
38
  const taskCount = loaded.tasks.length;
@@ -81,7 +81,7 @@ export function dispatchHealthRecovery(ctx: ExtensionContext, runId: string): Pr
81
81
 
82
82
  export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
83
83
  try {
84
- const loaded = loadRunManifestById(ctx.cwd, runId);
84
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
85
85
  if (!loaded) return { ok: false, message: `Run '${runId}' not found.` };
86
86
  const currentMs = Date.now();
87
87
  const staleMs = 60_000;
@@ -111,8 +111,9 @@ export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: str
111
111
  }
112
112
  }
113
113
 
114
- export function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
115
- const loaded = loadRunManifestById(ctx.cwd, runId);
114
+ /** @internal */
115
+ function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
116
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
116
117
  if (!loaded) return undefined;
117
118
  return readCrewAgents(loaded.manifest).find((agent) => agent.status === "running" || agent.status === "queued")?.taskId;
118
119
  }
@@ -652,7 +652,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
652
652
  function build(runId: string, previous?: CacheEntry): CacheEntry {
653
653
  let loaded: ReturnType<typeof loadRunManifestById>;
654
654
  try {
655
- loaded = loadRunManifestById(cwd, runId);
655
+ loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
656
656
  } catch {
657
657
  if (previous) return previous;
658
658
  throw new Error(`Run '${runId}' could not be parsed.`);
@@ -47,7 +47,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
47
47
  }
48
48
  }
49
49
 
50
- export function colorForActivity(activityState: string | undefined): CrewThemeColor {
50
+ /** @internal */
51
+ function colorForActivity(activityState: string | undefined): CrewThemeColor {
51
52
  if (activityState === "needs_attention") return "warning";
52
53
  if (activityState === "stale") return "error";
53
54
  return "dim";
@@ -22,7 +22,8 @@ function buildCliTheme(theme: CrewTheme): Record<string, (text: string) => strin
22
22
  };
23
23
  }
24
24
 
25
- export function detectLanguageFromPath(filePath: string): string | undefined {
25
+ /** @internal */
26
+ function detectLanguageFromPath(filePath: string): string | undefined {
26
27
  const ext = filePath.split(".").pop()?.toLowerCase();
27
28
  if (!ext) return undefined;
28
29
  return languageMap[ext];
@@ -29,6 +29,15 @@ export interface AgentToolResultDetails {
29
29
  results?: Array<{ agentId?: string; status?: string; output?: string; error?: string }>;
30
30
  }
31
31
 
32
+ /** Combined type for renderAgentToolResult — handles both nested details and flat result shapes */
33
+ interface AgentResultData extends AgentToolResultDetails {
34
+ agentId?: string;
35
+ status?: string;
36
+ error?: string;
37
+ output?: string;
38
+ runId?: string;
39
+ }
40
+
32
41
  // ── Helpers ─────────────────────────────────────────────────────────
33
42
 
34
43
  export function formatTokens(n: number): string {
@@ -45,7 +54,8 @@ export function formatDuration(ms: number): string {
45
54
  return s > 0 ? `${m}m${s}s` : `${m}m`;
46
55
  }
47
56
 
48
- export function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
57
+ /** @internal */
58
+ function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
49
59
  if (!contextWindow) return `${formatTokens(tokens)} ctx`;
50
60
  const pct = (tokens / contextWindow) * 100;
51
61
  const maxStr = contextWindow >= 1_000_000 ? `${(contextWindow / 1_000_000).toFixed(1)}M` : `${Math.round(contextWindow / 1000)}k`;
@@ -294,7 +304,7 @@ export function renderAgentToolResult(
294
304
  _options: unknown, theme: Theme, _context: unknown,
295
305
  ): Component {
296
306
  // Handle both nested details and flattened result shape
297
- const d = (result as any).details ?? result;
307
+ const d = (result.details ?? result) as AgentResultData;
298
308
  const c = new Container();
299
309
  const w = 116;
300
310
 
@@ -351,7 +361,7 @@ export function renderAgentToolResult(
351
361
  function extractText(content: unknown[] | undefined): string {
352
362
  if (!content) return "(no output)";
353
363
  if (!Array.isArray(content)) return String(content);
354
- return content.filter((c: any) => c?.type === "text").map((c: any) => c?.text ?? "").join("\n") || "(no output)";
364
+ return content.filter((c): c is Record<string, unknown> => typeof c === "object" && c !== null && (c as Record<string, unknown>).type === "text").map((c) => String((c as Record<string, unknown>).text ?? "")).join("\n") || "(no output)";
355
365
  }
356
366
 
357
367
  function parseArgs(argsStr: string | undefined): Record<string, unknown> {
@@ -1,10 +1,56 @@
1
1
  import { isSecretKey } from "./redaction.ts";
2
2
 
3
+ // Well-known LLM provider API keys that are intentionally allowlisted in
4
+ // child-pi.ts and async-runner.ts for pass-through to worker processes.
5
+ // These are "secret" by pattern (contain KEY/API) but are safe to allowlist
6
+ // because they are standard provider credentials, not arbitrary secrets.
7
+ const KNOWN_PROVIDER_KEYS = new Set([
8
+ "MINIMAX_API_KEY", "MINIMAX_GROUP_ID",
9
+ "OPENAI_API_KEY", "OPENAI_ORG_ID",
10
+ "ANTHROPIC_API_KEY",
11
+ "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
12
+ "AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT",
13
+ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION",
14
+ "ZEU_API_KEY", "ZERODEV_API_KEY",
15
+ ]);
16
+
17
+ function isKnownProviderKey(key: string): boolean {
18
+ return KNOWN_PROVIDER_KEYS.has(key);
19
+ }
20
+
3
21
  export interface SanitizeEnvOptions {
4
22
  /** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
5
23
  allowList?: string[];
6
24
  }
7
25
 
26
+ // Keywords that indicate a secret env var
27
+ const SECRET_SUFFIXES = ["token", "api", "key", "password", "passwd", "secret", "credential", "authorization", "private"];
28
+
29
+ /**
30
+ * Check if a glob pattern could match secret env vars.
31
+ * A pattern like "PI_*" is dangerous because it could match PI_TOKEN, PI_API_KEY, etc.
32
+ *
33
+ * Exception: `PI_CREW_*` is a controlled namespace — the pi-crew codebase owns
34
+ * every PI_CREW_* env var (PI_CREW_PARENT_PID, PI_CREW_ADAPTIVE_REPAIR, etc.)
35
+ * and none of them are secrets. Allowing the glob here lets child Pi processes
36
+ * inherit our config without needing a per-var allowlist.
37
+ */
38
+ function isDangerousGlob(pattern: string): boolean {
39
+ if (!pattern.endsWith("*")) return false;
40
+ const prefix = pattern.slice(0, -1); // Remove trailing *
41
+ if (prefix === "") return true; // Single "*" matches everything
42
+ // PI_CREW_* is the pi-crew controlled namespace — no secrets live here.
43
+ // This covers PI_CREW_*, PI_CREW_TEAMS_*, PI_CREW_AGENT_*, etc.
44
+ if (prefix.startsWith("PI_CREW_") || prefix === "PI_CREW") return false;
45
+ // Check if combining the prefix with any secret suffix would create a secret key
46
+ for (const suffix of SECRET_SUFFIXES) {
47
+ if (isSecretKey(prefix + suffix)) {
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+
8
54
  /**
9
55
  * Strip env vars whose keys look like secrets before passing to child processes.
10
56
  *
@@ -14,11 +60,31 @@ export interface SanitizeEnvOptions {
14
60
  export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnvOptions): Record<string, string> {
15
61
  const filtered: Record<string, string> = {};
16
62
  if (options?.allowList && options.allowList.length > 0) {
63
+ // Validate allowlist patterns don't match secrets
64
+ for (const pattern of options.allowList) {
65
+ if (isDangerousGlob(pattern)) {
66
+ throw new Error(`Allowlist pattern "${pattern}" could match secret env vars. Use a more specific pattern.`);
67
+ }
68
+ // Validate non-glob entries don't look like secret keys.
69
+ // Exception 1: if the key exists in env, it was intentionally set and
70
+ // should be allowed through (user knows what they're doing).
71
+ // Exception 2: known provider keys (MINIMAX_API_KEY, etc.) are always
72
+ // allowed because they are standard provider credentials explicitly
73
+ // listed in async-runner.ts / child-pi.ts allowlists.
74
+ if (!pattern.endsWith("*") && isSecretKey(pattern) && !(pattern in env) && !isKnownProviderKey(pattern)) {
75
+ throw new Error(`Allowlist entry "${pattern}" looks like a secret key. Use a more specific pattern.`);
76
+ }
77
+ }
17
78
  const matchers = options.allowList.map((p) => {
18
79
  if (p.endsWith("*")) {
80
+ // Glob pattern: matches keys that start with the prefix AND have
81
+ // at least one additional character (distinguishes "PI_CREW_*" from "PI_CREW_").
82
+ // For example, "PI_CREW_*" matches "PI_CREW_DEPTH" but not "PI_CREW_".
83
+ // This ensures trailing glob patterns require extra chars, not exact-prefix-only matches.
19
84
  const prefix = p.slice(0, -1);
20
85
  return (k: string) => k.startsWith(prefix) && k.length > prefix.length;
21
86
  }
87
+ // Exact match is case-sensitive; Unix env vars are uppercase by convention.
22
88
  return (k: string) => k === p;
23
89
  });
24
90
  for (const [key, value] of Object.entries(env)) {
@@ -6,7 +6,15 @@ interface TimerApi {
6
6
  }
7
7
 
8
8
  const defaultTimerApi: TimerApi = {
9
- setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
9
+ setTimeout: (handler, delayMs) => {
10
+ const t = setTimeout(handler, delayMs);
11
+ // Defense in depth: never let a coalescer timer block process exit.
12
+ // The timer may be cleared before it fires; .unref() is idempotent.
13
+ if (typeof t === "object" && t && "unref" in t && typeof t.unref === "function") {
14
+ t.unref();
15
+ }
16
+ return t;
17
+ },
10
18
  clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
11
19
  };
12
20
 
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { FSWatcher, WatchListener } from "node:fs";
4
4
 
5
- export const FS_WATCH_RETRY_DELAY_MS = 5000;
5
+ /** @internal */
6
+ const FS_WATCH_RETRY_DELAY_MS = 5000;
6
7
 
7
8
  export function closeWatcher(watcher: FSWatcher | null | undefined): void {
8
9
  if (!watcher) {
@@ -85,4 +86,5 @@ export function watchCrewState(
85
86
  }
86
87
 
87
88
  // Re-export path helper so callers don't pull node:path just for join.
88
- export const joinPath = path.join;
89
+ /** @internal */
90
+ const joinPath = path.join;
@@ -473,7 +473,8 @@ export function resolveGitHubUrl(parsed: Parsed, scheme: "issue" | "pr", cwd: st
473
473
  * Resolve a raw `issue://` or `pr://` URL string.
474
474
  * Convenience wrapper combining parse + resolve.
475
475
  */
476
- export function resolveGitHubProtocol(raw: string, scheme: "issue" | "pr", cwd: string): GhResult<unknown> {
476
+ /** @internal */
477
+ function resolveGitHubProtocol(raw: string, scheme: "issue" | "pr", cwd: string): GhResult<unknown> {
477
478
  const parsed = parseGitHubUrl(raw, scheme);
478
479
  return resolveGitHubUrl(parsed, scheme, cwd);
479
480
  }
@@ -9,7 +9,49 @@ export function packageRoot(): string {
9
9
 
10
10
  export function userPiRoot(): string {
11
11
  const home = process.env.PI_TEAMS_HOME?.trim() || os.homedir();
12
- return path.join(home, ".pi", "agent");
12
+ const resolved = path.join(home, ".pi", "agent");
13
+
14
+ // Reject symlinks to prevent confusion attacks where PI_TEAMS_HOME points to
15
+ // an attacker-controlled target via a user-owned symlink.
16
+ // We use lstatSync (does NOT follow) to detect symlinks before they are resolved.
17
+ // ENOENT is acceptable — the directory may not exist yet (caller will create it).
18
+ let isSymlink = false;
19
+ try {
20
+ const lstats = fs.lstatSync(resolved);
21
+ isSymlink = lstats.isSymbolicLink();
22
+ } catch (err: unknown) {
23
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") throw err;
24
+ // Path doesn't exist yet — caller will create it. Skip further validation.
25
+ return resolved;
26
+ }
27
+ if (isSymlink) {
28
+ throw new Error(
29
+ `userPiRoot: PI_TEAMS_HOME path "${resolved}" is a symlink. ` +
30
+ "Symlinks are not supported for PI_TEAMS_HOME to prevent confusion attacks. " +
31
+ "Set PI_TEAMS_HOME to a direct path owned by the current user.",
32
+ );
33
+ }
34
+
35
+ // Validate that the resolved path is owned by the current user
36
+ // to ensure security assumptions about file permissions (0o600/0o700) hold.
37
+ // Skip check if the directory does not exist yet.
38
+ try {
39
+ const stats = fs.statSync(resolved);
40
+ if (stats.uid !== os.userInfo().uid) {
41
+ throw new Error(
42
+ `userPiRoot: PI_TEAMS_HOME path "${resolved}" is not owned by the current user (uid=${os.userInfo().uid}, found uid=${stats.uid}). ` +
43
+ "This violates security assumptions about file permissions. Set PI_TEAMS_HOME to a path owned by the current user, or unset it to use the default.",
44
+ );
45
+ }
46
+ } catch (err: unknown) {
47
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
48
+ throw err;
49
+ }
50
+ // ENOENT from statSync means the directory was deleted between lstat and stat
51
+ // (race condition). This is acceptable — caller will handle.
52
+ }
53
+
54
+ return resolved;
13
55
  }
14
56
 
15
57
  const PROJECT_DIR_MARKERS = [".git", ".pi", ".crew", ".hg", ".svn", ".factory", ".omc"];
@@ -48,8 +90,34 @@ function hasProjectMarker(dir: string): boolean {
48
90
  return false;
49
91
  }
50
92
 
93
+ /** On Windows, resolve a path to its canonical long-name form.
94
+ * Uses realpathSync.native to get the \\?\ long-name path, then strips
95
+ * the prefix for path.relative compatibility. */
96
+ function canonicalizePath(p: string): string {
97
+ try {
98
+ const r = fs.realpathSync.native(p);
99
+ return r.startsWith("\\\\?\\") ? r.slice(4) : r;
100
+ } catch {
101
+ try { return fs.realpathSync(p); } catch { return p; }
102
+ }
103
+ }
104
+
51
105
  export function findRepoRoot(cwd: string): string | undefined {
52
- const startKey = path.resolve(cwd);
106
+ // Resolve symlinks before walking to prevent malicious symlinks from bypassing
107
+ // home/temp boundary checks. If the path doesn't exist (e.g., caller passed
108
+ // a non-existent CWD like /tmp/no-such-dir), fall back to the lexical path
109
+ // and let computeRepoRoot handle the rest. ENOENT here is common for
110
+ // newly-created test directories and shouldn't propagate as a crash.
111
+ let startKey: string;
112
+ try {
113
+ startKey = fs.realpathSync(cwd);
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
116
+ startKey = path.resolve(cwd);
117
+ } else {
118
+ throw error;
119
+ }
120
+ }
53
121
  const cached = projectRootCache.get(startKey);
54
122
  if (cached && Date.now() - cached.cachedAt < PROJECT_ROOT_CACHE_TTL_MS) {
55
123
  // Re-insert to refresh LRU position.
@@ -82,18 +150,25 @@ function computeRepoRoot(start: string): string | undefined {
82
150
  }
83
151
 
84
152
  export function projectPiRoot(cwd: string): string {
85
- return path.join(findRepoRoot(cwd) ?? cwd, ".pi");
153
+ const repoRoot = findRepoRoot(cwd) ?? cwd;
154
+ const piDir = path.join(repoRoot, ".pi");
155
+ // Use realpathSync to resolve any symlinks before returning to prevent
156
+ // config from being read from unexpected locations.
157
+ if (fs.existsSync(piDir)) return fs.realpathSync(piDir);
158
+ return piDir;
86
159
  }
87
160
 
88
161
  export function projectCrewRoot(cwd: string): string {
89
162
  const repoRoot = findRepoRoot(cwd) ?? cwd;
90
163
  const crewDir = path.join(repoRoot, ".crew");
91
164
  // Keep an existing .crew/ stable even when .pi/ exists for project config.
92
- if (fs.existsSync(crewDir)) return crewDir;
165
+ // Use canonicalizePath to get long-name form on Windows, matching
166
+ // what worktree operations and resolveRealContainedPath expect.
167
+ if (fs.existsSync(crewDir)) return canonicalizePath(crewDir);
93
168
  // Legacy reuse: if .pi/ already exists for the project, namespace under .pi/teams/
94
169
  // to avoid creating a parallel .crew/ alongside an existing pi project layout.
95
170
  const piDir = path.join(repoRoot, ".pi");
96
- if (fs.existsSync(piDir)) return path.join(piDir, "teams");
171
+ if (fs.existsSync(piDir)) return path.join(canonicalizePath(piDir), "teams");
97
172
  return crewDir;
98
173
  }
99
174
 
@@ -7,8 +7,13 @@
7
7
  export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g;
8
8
 
9
9
  // Linear-time secret key detection
10
+ // IMPORTANT: This function must maintain linear-time guarantees.
11
+ // The fast-path regex uses simple string alternatives with anchors only (no quantifiers),
12
+ // and the linear scan iterates through characters once. If either path is replaced with
13
+ // a more complex regex, catastrophic backtracking (ReDoS) could result.
14
+ // Any modifications must preserve O(n) complexity where n = keyName.length.
10
15
  export function isSecretKey(keyName: string): boolean {
11
- // Fast path: common secret key names
16
+ // Fast path: common secret key names (safe anchored regex, no backtracking)
12
17
  const lower = keyName.toLowerCase();
13
18
  if (/^(token|apikey|api_key|password|secret|credential|authorization|privatekey|private_key)$/.test(lower)) {
14
19
  return true;