pi-crew 0.5.2 → 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 (137) hide show
  1. package/CHANGELOG.md +183 -0
  2. package/README.md +17 -1
  3. package/docs/architecture.md +2 -0
  4. package/docs/bugs/cross-session-notification-leakage.md +82 -0
  5. package/docs/coding-agent-optimization.md +268 -0
  6. package/docs/deep-review-report.md +384 -0
  7. package/docs/distillation/cybersecurity-patterns.md +294 -0
  8. package/docs/migration-v0.4-v0.5.md +208 -0
  9. package/docs/optimization-plan.md +642 -0
  10. package/docs/pi-crew-v0.5.5-audit-fix-plan.md +133 -0
  11. package/docs/pi-mono-opportunities.md +969 -0
  12. package/docs/pi-mono-review.md +291 -0
  13. package/docs/skills/REFERENCE.md +144 -0
  14. package/package.json +12 -9
  15. package/skills/artifact-analysis-loop/SKILL.md +302 -0
  16. package/skills/async-worker-recovery/SKILL.md +19 -1
  17. package/skills/child-pi-spawning/SKILL.md +19 -6
  18. package/skills/context-artifact-hygiene/SKILL.md +19 -2
  19. package/skills/delegation-patterns/SKILL.md +68 -3
  20. package/skills/detection-pipeline-design/SKILL.md +285 -0
  21. package/skills/event-log-tracing/SKILL.md +20 -6
  22. package/skills/git-master/SKILL.md +20 -6
  23. package/skills/hunting-investigation-loop/SKILL.md +401 -0
  24. package/skills/incident-playbook-construction/SKILL.md +383 -0
  25. package/skills/live-agent-lifecycle/SKILL.md +20 -6
  26. package/skills/mailbox-interactive/SKILL.md +19 -6
  27. package/skills/model-routing-context/SKILL.md +19 -1
  28. package/skills/multi-perspective-review/SKILL.md +19 -4
  29. package/skills/observability-reliability/SKILL.md +19 -2
  30. package/skills/orchestration/SKILL.md +20 -2
  31. package/skills/ownership-session-security/SKILL.md +20 -2
  32. package/skills/pi-extension-lifecycle/SKILL.md +20 -2
  33. package/skills/post-mortem/SKILL.md +7 -2
  34. package/skills/read-only-explorer/SKILL.md +20 -6
  35. package/skills/requirements-to-task-packet/SKILL.md +23 -3
  36. package/skills/resource-discovery-config/SKILL.md +20 -2
  37. package/skills/runtime-state-reader/SKILL.md +20 -2
  38. package/skills/safe-bash/SKILL.md +21 -6
  39. package/skills/scrutinize/SKILL.md +20 -2
  40. package/skills/secure-agent-orchestration-review/SKILL.md +29 -2
  41. package/skills/security-review/SKILL.md +560 -0
  42. package/skills/state-mutation-locking/SKILL.md +22 -2
  43. package/skills/systematic-debugging/SKILL.md +8 -6
  44. package/skills/threat-hypothesis-framework/SKILL.md +175 -0
  45. package/skills/ui-render-performance/SKILL.md +20 -2
  46. package/skills/verification-before-done/SKILL.md +17 -2
  47. package/skills/widget-rendering/SKILL.md +21 -6
  48. package/skills/workspace-isolation/SKILL.md +20 -6
  49. package/skills/worktree-isolation/SKILL.md +20 -6
  50. package/src/agents/agent-config.ts +40 -1
  51. package/src/benchmark/benchmark-runner.ts +45 -0
  52. package/src/benchmark/feedback-loop.ts +5 -0
  53. package/src/config/config.ts +32 -5
  54. package/src/config/role-tools.ts +82 -0
  55. package/src/config/suggestions.ts +8 -0
  56. package/src/config/types.ts +4 -0
  57. package/src/extension/async-notifier.ts +10 -1
  58. package/src/extension/crew-cleanup.ts +114 -0
  59. package/src/extension/cross-extension-rpc.ts +1 -1
  60. package/src/extension/notification-router.ts +18 -0
  61. package/src/extension/register.ts +27 -19
  62. package/src/extension/registration/subagent-tools.ts +1 -1
  63. package/src/extension/team-tool/anchor.ts +201 -0
  64. package/src/extension/team-tool/api.ts +2 -1
  65. package/src/extension/team-tool/auto-summarize.ts +154 -0
  66. package/src/extension/team-tool/run.ts +42 -7
  67. package/src/extension/team-tool.ts +44 -2
  68. package/src/hooks/registry.ts +1 -3
  69. package/src/observability/event-bus.ts +69 -0
  70. package/src/observability/event-to-metric.ts +0 -2
  71. package/src/runtime/anchor-manager.ts +473 -0
  72. package/src/runtime/async-runner.ts +8 -4
  73. package/src/runtime/auto-summarize.ts +350 -0
  74. package/src/runtime/background-runner.ts +10 -3
  75. package/src/runtime/budget-tracker.ts +354 -0
  76. package/src/runtime/chain-runner.ts +507 -0
  77. package/src/runtime/child-pi.ts +123 -35
  78. package/src/runtime/crash-recovery.ts +5 -4
  79. package/src/runtime/crew-agent-runtime.ts +1 -0
  80. package/src/runtime/custom-tools/irc-tool.ts +13 -0
  81. package/src/runtime/custom-tools/submit-result-tool.ts +3 -2
  82. package/src/runtime/delivery-coordinator.ts +10 -3
  83. package/src/runtime/dynamic-script-runner.ts +482 -0
  84. package/src/runtime/foreground-control.ts +87 -17
  85. package/src/runtime/handoff-manager.ts +589 -0
  86. package/src/runtime/hidden-handoff.ts +424 -0
  87. package/src/runtime/live-agent-manager.ts +20 -4
  88. package/src/runtime/live-session-runtime.ts +39 -4
  89. package/src/runtime/manifest-cache.ts +2 -1
  90. package/src/runtime/model-resolver.ts +16 -4
  91. package/src/runtime/phase-tracker.ts +373 -0
  92. package/src/runtime/pi-args.ts +11 -1
  93. package/src/runtime/pi-json-output.ts +31 -0
  94. package/src/runtime/pipeline-runner.ts +514 -0
  95. package/src/runtime/progress-tracker.ts +124 -0
  96. package/src/runtime/retry-runner.ts +354 -0
  97. package/src/runtime/sandbox.ts +252 -0
  98. package/src/runtime/scheduler.ts +7 -2
  99. package/src/runtime/skill-effectiveness.ts +473 -0
  100. package/src/runtime/skill-instructions.ts +37 -3
  101. package/src/runtime/subagent-manager.ts +1 -1
  102. package/src/runtime/task-graph.ts +11 -1
  103. package/src/runtime/task-runner.ts +92 -18
  104. package/src/runtime/team-runner.ts +13 -12
  105. package/src/runtime/tool-progress.ts +10 -3
  106. package/src/runtime/verification-gates.ts +367 -0
  107. package/src/schema/team-tool-schema.ts +37 -0
  108. package/src/skills/discover-skills.ts +5 -0
  109. package/src/state/active-run-registry.ts +9 -2
  110. package/src/state/contracts.ts +9 -0
  111. package/src/state/crew-init.ts +3 -3
  112. package/src/state/decision-ledger.ts +98 -55
  113. package/src/state/event-log-rotation.ts +2 -2
  114. package/src/state/event-log.ts +144 -10
  115. package/src/state/hook-instinct-bridge.ts +5 -5
  116. package/src/state/mailbox.ts +10 -0
  117. package/src/state/run-cache.ts +18 -8
  118. package/src/state/state-store.ts +3 -1
  119. package/src/state/types.ts +4 -0
  120. package/src/tools/safe-bash-extension.ts +1 -0
  121. package/src/tools/safe-bash.ts +152 -20
  122. package/src/types/new-api-types.ts +34 -0
  123. package/src/ui/agent-management-overlay.ts +5 -1
  124. package/src/ui/crew-widget.ts +29 -15
  125. package/src/ui/overlays/mailbox-detail-overlay.ts +13 -2
  126. package/src/ui/powerbar-publisher.ts +101 -7
  127. package/src/ui/tool-render.ts +15 -15
  128. package/src/ui/transcript-cache.ts +13 -0
  129. package/src/utils/bm25-search.ts +16 -8
  130. package/src/utils/env-filter.ts +8 -5
  131. package/src/utils/redaction.ts +169 -15
  132. package/src/utils/session-utils.ts +52 -0
  133. package/src/utils/sse-parser.ts +10 -1
  134. package/src/worktree/cleanup.ts +6 -1
  135. package/src/worktree/worktree-manager.ts +32 -13
  136. package/workflows/chain.workflow.md +252 -0
  137. package/workflows/pipeline.workflow.md +27 -0
@@ -90,12 +90,12 @@ export function renderTeamToolCall(
90
90
  if (!context.expanded) {
91
91
  const preview = goal.length > 60 ? goal.slice(0, 60) + "…" : goal;
92
92
  return new Text(
93
- `${theme.fg("toolTitle", theme.bold("team"))} action=${theme.fg("accent", `'${action}'`)}${team}${theme.fg("dim", preview ? ` "${preview.replace(/\n/g, " ")}"` : "")}`,
93
+ `${theme.fg("toolTitle", theme.bold("team"))} action=${theme.fg("accent", `'${action}'`)}${team}${theme.fg("dim", preview ? ` "${preview.replace(/\n/g, " ")}"` : "")}`,
94
94
  0, 0,
95
95
  );
96
96
  }
97
97
  const c = context.lastComponent instanceof Container ? (context.lastComponent.clear(), context.lastComponent) : new Container();
98
- c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("team"))} action=${theme.fg("accent", `'${action}'`)}${team}`, 0, 0));
98
+ c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("team"))} action=${theme.fg("accent", `'${action}'`)}${team}`, 0, 0));
99
99
  if (goal) { c.addChild(new Spacer(1)); c.addChild(new Text(theme.fg("text", goal), 0, 0)); }
100
100
  return c;
101
101
  }
@@ -110,13 +110,13 @@ export function renderAgentToolCall(
110
110
  if (!context.expanded) {
111
111
  const preview = prompt.length > 60 ? prompt.slice(0, 60) + "…" : prompt;
112
112
  return new Text(
113
- `${theme.fg("toolTitle", theme.bold("agent"))} ${theme.fg("accent", agentName)}${theme.fg("dim", preview ? ` "${preview.replace(/\n/g, " ")}"` : "")}`,
113
+ `${theme.fg("toolTitle", theme.bold("agent"))} ${theme.fg("accent", agentName)}${theme.fg("dim", preview ? ` "${preview.replace(/\n/g, " ")}"` : "")}`,
114
114
  0, 0,
115
115
  );
116
116
  }
117
117
  const c = context.lastComponent instanceof Container ? (context.lastComponent.clear(), context.lastComponent) : new Container();
118
- const cwdLabel = args.cwd ? theme.fg("dim", ` (cwd: ${args.cwd})`) : "";
119
- c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("agent"))} ${theme.fg("accent", agentName)}${cwdLabel}`, 0, 0));
118
+ const cwdLabel = args.cwd ? theme.fg("dim", ` (cwd: ${args.cwd})`) : "";
119
+ c.addChild(new Text(`${theme.fg("toolTitle", theme.bold("agent"))} ${theme.fg("accent", agentName)}${cwdLabel}`, 0, 0));
120
120
  if (prompt) { c.addChild(new Spacer(1)); c.addChild(new Text(theme.fg("text", prompt), 0, 0)); }
121
121
  return c;
122
122
  }
@@ -153,21 +153,21 @@ export function renderAgentProgress(
153
153
  const stats = `${prog?.toolCount ?? record.toolUses ?? 0} tools · ${formatDuration(durationMs)}`;
154
154
  const modelStr = record.model ? ` (${record.model})` : "";
155
155
  const roleLabel = record.role || record.agent || "agent";
156
- addLine(`${icon} ${theme.fg("toolTitle", theme.bold(roleLabel))}${theme.fg("dim", modelStr)} ${theme.fg("dim", stats)}`);
156
+ addLine(`${icon} ${theme.fg("toolTitle", theme.bold(roleLabel))}${theme.fg("dim", modelStr)} ${theme.fg("dim", stats)}`);
157
157
 
158
158
  // Current tool (running)
159
159
  if (isRunning && prog?.currentTool) {
160
160
  const toolLabel = formatToolPreview(prog.currentTool, parseArgs(prog.currentToolArgs));
161
- addLine(theme.fg("warning", `▸ ${prog.currentTool}: ${toolLabel}`));
161
+ addLine(theme.fg("warning", `▸ ${prog.currentTool}: ${toolLabel}`));
162
162
  }
163
163
 
164
164
  // Recent tools log
165
165
  if (prog?.recentTools?.length) {
166
166
  for (const tool of prog.recentTools) {
167
- const detail = tool.args ? `: ${tool.args}` : "";
167
+ const detail = tool.args ? `: ${tool.args}` : "";
168
168
  const line = tool.endedAt
169
169
  ? theme.fg("muted", ` ${tool.tool}${detail}`)
170
- : theme.fg("warning", `▸ ${tool.tool}${detail}`);
170
+ : theme.fg("warning", `▸ ${tool.tool}${detail}`);
171
171
  addLine(line);
172
172
  }
173
173
  }
@@ -231,7 +231,7 @@ export function renderTeamToolResult(
231
231
  if ((result as any).runId) parts.push(`runId=${(result as any).runId}`);
232
232
  if ((result as any).error) parts.push(theme.fg("error", `error`));
233
233
  if ((result as any).goal && parts.length === 0) parts.push(theme.fg("dim", truncLine((result as any).goal, 116)));
234
- return new Text(parts.join(" "), 0, 0);
234
+ return new Text(parts.join(" · "), 0, 0);
235
235
  }
236
236
  // No details found, fall back to content
237
237
  const text = extractText(result?.content).slice(0, 200);
@@ -253,7 +253,7 @@ export function renderTeamToolResult(
253
253
  if (d.error) parts.push(theme.fg("error", `error=${d.error}`));
254
254
  if (d.goal) parts.push(theme.fg("dim", truncLine(d.goal, 116)));
255
255
  if (parts.length === 0) return new Text(theme.fg("muted", "(no output)"), 0, 0);
256
- return new Text(parts.join(" · "), 0, 0);
256
+ return new Text(parts.join(" · "), 0, 0);
257
257
  }
258
258
 
259
259
  /** agent tool result: shows agent output rows with status icons */
@@ -275,9 +275,9 @@ export function renderAgentToolResult(
275
275
  : item.status === "running" ? theme.fg("warning", "⟳")
276
276
  : theme.fg("dim", "○");
277
277
  const label = item.agentId || "agent";
278
- c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
278
+ c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
279
279
  if (item.error) {
280
- c.addChild(new Text(theme.fg("error", ` Error: ${item.error}`), 0, 0));
280
+ c.addChild(new Text(theme.fg("error", ` Error: ${item.error}`), 0, 0));
281
281
  } else if (item.output) {
282
282
  for (const line of item.output.split("\n").slice(0, 5))
283
283
  c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
@@ -293,9 +293,9 @@ export function renderAgentToolResult(
293
293
  : d.status === "running" ? theme.fg("warning", "⟳")
294
294
  : theme.fg("dim", "○");
295
295
  const label = d.agentId;
296
- c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
296
+ c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
297
297
  if (d.error) {
298
- c.addChild(new Text(theme.fg("error", ` Error: ${d.error}`), 0, 0));
298
+ c.addChild(new Text(theme.fg("error", ` Error: ${d.error}`), 0, 0));
299
299
  } else if (d.output) {
300
300
  for (const line of d.output.split("\n").slice(0, 5))
301
301
  c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
@@ -19,6 +19,7 @@ export interface TranscriptReadOptions {
19
19
 
20
20
  const TRANSCRIPT_CACHE_TTL_MS = 500;
21
21
  const DEFAULT_TAIL_BYTES = 256 * 1024;
22
+ const MAX_CACHE_SIZE = 100;
22
23
  const transcriptCache = new Map<string, TranscriptCacheEntry>();
23
24
 
24
25
  function cacheKey(path: string, options: Required<Pick<TranscriptReadOptions, "full">> & { maxTailBytes: number }): string {
@@ -85,6 +86,18 @@ export function readTranscriptLinesCached(path: string, parse: (text: string) =>
85
86
  truncated: read.truncated,
86
87
  };
87
88
  transcriptCache.set(key, entry);
89
+ // Evict oldest entry if cache exceeds max size
90
+ if (transcriptCache.size > MAX_CACHE_SIZE) {
91
+ let oldestKey: string | null = null;
92
+ let oldestParsedAt = Infinity;
93
+ for (const [k, v] of transcriptCache.entries()) {
94
+ if (v.parsedAt < oldestParsedAt) {
95
+ oldestParsedAt = v.parsedAt;
96
+ oldestKey = k;
97
+ }
98
+ }
99
+ if (oldestKey) transcriptCache.delete(oldestKey);
100
+ }
88
101
  return lines;
89
102
  } catch {
90
103
  return previous?.lines ?? [];
@@ -46,17 +46,17 @@ export class BM25Search<T extends SearchDocument> {
46
46
  }
47
47
 
48
48
  /**
49
- * Compute document frequency for a query term using substring matching,
50
- * consistent with the regex-based tf computation in search().
49
+ * Compute document frequency for a query term using indexOf for better performance.
50
+ * Uses linear-time substring matching instead of regex to avoid ReDoS.
51
51
  */
52
52
  private df(term: string): number {
53
- const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
- const re = new RegExp(escaped, "g");
53
+ const termLower = term.toLowerCase();
55
54
  let count = 0;
56
55
  for (const doc of this.documents) {
57
56
  for (const field of Object.keys(this.fieldWeights)) {
58
57
  const text = (doc.fields[field] ?? "").toLowerCase();
59
- if (re.test(text)) {
58
+ // Use indexOf for linear-time substring search
59
+ if (text.includes(termLower)) {
60
60
  count++;
61
61
  break;
62
62
  }
@@ -81,11 +81,19 @@ export class BM25Search<T extends SearchDocument> {
81
81
  let fieldScore = 0;
82
82
 
83
83
  for (const term of queryTerms) {
84
- const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85
- const tf = (textLower.match(new RegExp(escaped, "g")) ?? []).length;
84
+ // Use indexOf for linear-time substring counting instead of regex
85
+ const termLower = term.toLowerCase();
86
+ let tf = 0;
87
+ let pos = 0;
88
+ while ((pos = textLower.indexOf(termLower, pos)) !== -1) {
89
+ tf++;
90
+ pos += termLower.length;
91
+ // Cap tf to prevent runaway on repeated patterns
92
+ if (tf > 100) break;
93
+ }
86
94
  if (tf === 0) continue;
87
95
 
88
- const df = this.df(term);
96
+ const df = this.df(termLower);
89
97
  if (df === 0) continue;
90
98
 
91
99
  const idf = Math.log((this.N - df + 0.5) / (df + 0.5) + 1);
@@ -1,4 +1,4 @@
1
- import { SECRET_KEY_PATTERN } from "./redaction.ts";
1
+ import { isSecretKey } from "./redaction.ts";
2
2
 
3
3
  export interface SanitizeEnvOptions {
4
4
  /** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
@@ -8,14 +8,17 @@ export interface SanitizeEnvOptions {
8
8
  /**
9
9
  * Strip env vars whose keys look like secrets before passing to child processes.
10
10
  *
11
- * Default mode (no allowList): deny-list using SECRET_KEY_PATTERN.
11
+ * Default mode (no allowList): deny-list using isSecretKey.
12
12
  * When allowList is provided, only keys matching the allow-list are preserved.
13
13
  */
14
14
  export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnvOptions): Record<string, string> {
15
15
  const filtered: Record<string, string> = {};
16
16
  if (options?.allowList && options.allowList.length > 0) {
17
17
  const matchers = options.allowList.map((p) => {
18
- if (p.endsWith("*")) return (k: string) => k.startsWith(p.slice(0, -1));
18
+ if (p.endsWith("*")) {
19
+ const prefix = p.slice(0, -1);
20
+ return (k: string) => k.startsWith(prefix) && k.length > prefix.length;
21
+ }
19
22
  return (k: string) => k === p;
20
23
  });
21
24
  for (const [key, value] of Object.entries(env)) {
@@ -24,7 +27,7 @@ export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnv
24
27
  return filtered;
25
28
  }
26
29
  for (const [key, value] of Object.entries(env)) {
27
- if (value !== undefined && !SECRET_KEY_PATTERN.test(key)) filtered[key] = value;
30
+ if (value !== undefined && !isSecretKey(key)) filtered[key] = value;
28
31
  }
29
32
  return filtered;
30
- }
33
+ }
@@ -1,26 +1,180 @@
1
- export const SECRET_KEY_PATTERN = /(?:^|[_.-])(token|api[-_]?key|password|passwd|secret|credential|authorization|private[-_]?key)(?:$|[_.-])/i;
2
- const INLINE_SECRET_PATTERN = /(^|[\s,{])(([A-Za-z0-9_.-]*(?:api[-_]?key|token|password|passwd|secret|credential|authorization|private[-_]?key)[A-Za-z0-9_.-]*)\s*[=:]\s*)([^\s,;"'}]+)/gi;
3
- const AUTH_HEADER_PATTERN = /\b(Authorization\s*:\s*(?:Bearer|Basic|Token)?\s*)([^\r\n]+)/gi;
4
- const BEARER_PATTERN = /\b(Bearer\s+)([A-Za-z0-9._~+/=-]{8,})\b/g;
5
- const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,65536}?-----END [A-Z ]*PRIVATE KEY-----/g;
1
+ /**
2
+ * ReDoS-resistant pattern matching for secret detection.
3
+ * Uses linear-time scan instead of complex regex to prevent catastrophic backtracking.
4
+ */
5
+
6
+ // Pattern for PEM private keys (possessive quantifier prevents backtracking)
7
+ export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g;
8
+
9
+ // Linear-time secret key detection
10
+ export function isSecretKey(keyName: string): boolean {
11
+ // Fast path: common secret key names
12
+ const lower = keyName.toLowerCase();
13
+ if (/^(token|apikey|api_key|password|secret|credential|authorization|privatekey|private_key)$/.test(lower)) {
14
+ return true;
15
+ }
16
+ // Linear scan for prefix characters followed by keywords
17
+ const prefixes = "_.-";
18
+ const keywords = ["token", "api", "key", "password", "passwd", "secret", "credential", "authorization", "private"];
19
+
20
+ for (let i = 0; i < keyName.length; i++) {
21
+ if (prefixes.includes(keyName[i])) {
22
+ const remaining = keyName.substring(i + 1).toLowerCase();
23
+ for (const kw of keywords) {
24
+ if (remaining.startsWith(kw)) {
25
+ const afterKw = remaining.substring(kw.length);
26
+ if (afterKw === "" || prefixes.includes(afterKw[0]) || /[a-zA-Z0-9]/.test(afterKw[0])) {
27
+ return true;
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ return false;
34
+ }
35
+
36
+ // Linear-time Authorization header redaction
37
+ export function redactAuthHeader(line: string): string {
38
+ const lower = line.toLowerCase();
39
+ const authIdx = lower.indexOf("authorization:");
40
+ if (authIdx === -1) return line;
41
+
42
+ // Verify word boundary - must be at start of line or preceded by whitespace/comma/brace
43
+ if (authIdx > 0) {
44
+ const before = line[authIdx - 1];
45
+ if (before !== ' ' && before !== ',' && before !== '{' && before !== '[' && before !== '"' && before !== '\r' && before !== '\n') {
46
+ return line; // Not a word boundary
47
+ }
48
+ }
49
+
50
+ // Check if this is followed by Bearer token (don't redact Bearer tokens separately)
51
+ // Look for "Bearer" after "authorization:"
52
+ const afterAuth = lower.substring(authIdx + 14).trimStart();
53
+ if (!afterAuth.startsWith('bearer ')) {
54
+ // No Bearer token, this is a regular Authorization header - redact it
55
+ let end = authIdx + 14;
56
+ while (end < line.length && line[end] !== "\r" && line[end] !== "\n") {
57
+ end++;
58
+ }
59
+ return line.substring(0, end) + " ***" + (end < line.length ? line.substring(end) : "");
60
+ }
61
+
62
+ // It's a Bearer token format - don't redact here, let redactBearerTokens handle it
63
+ return line;
64
+ }
65
+
66
+ // Linear-time Bearer token redaction
67
+ export function redactBearerTokens(line: string): string {
68
+ const upper = line.toUpperCase();
69
+ const result: string[] = [];
70
+ let i = 0;
71
+
72
+ while (i < line.length) {
73
+ if (upper.startsWith("BEARER ", i)) {
74
+ // Check word boundary: preceded by start, space, comma, brace, or newline
75
+ if (i > 0) {
76
+ const before = line[i - 1];
77
+ if (before !== ' ' && before !== ',' && before !== '{' && before !== '[' && before !== '"' && before !== '\r' && before !== '\n') {
78
+ result.push(line[i]);
79
+ i++;
80
+ continue;
81
+ }
82
+ }
83
+
84
+ // Found "Bearer " - now find the token
85
+ const bearerPrefix = line.substring(i, i + 7); // "Bearer "
86
+ let j = i + 7;
87
+ let tokenLen = 0;
88
+ while (j < line.length && tokenLen < 200 && /[A-Za-z0-9._~+/-]/.test(line[j])) {
89
+ j++;
90
+ tokenLen++;
91
+ }
92
+
93
+ if (tokenLen >= 8) {
94
+ // Replace with Bearer + *** (redact the token)
95
+ result.push(bearerPrefix + "***");
96
+ i = j;
97
+ continue;
98
+ }
99
+ }
100
+ result.push(line[i]);
101
+ i++;
102
+ }
103
+
104
+ return result.join("");
105
+ }
6
106
 
7
107
  function isRecord(value: unknown): value is Record<string, unknown> {
8
108
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
9
- // Exclude built-in types whose Object.entries() would produce empty arrays.
10
109
  if (value instanceof Date || value instanceof RegExp || value instanceof Error || value instanceof Map || value instanceof Set) return false;
11
110
  return true;
12
111
  }
13
112
 
14
- function isSecretKey(keyName: string): boolean {
15
- return SECRET_KEY_PATTERN.test(keyName) || /^(token|apiKey|api_key|password|secret|credential|authorization|privateKey|private_key)$/i.test(keyName);
113
+ export function redactSecretString(value: string): string {
114
+ let result = value;
115
+
116
+ // Replace PEM private keys
117
+ result = result.replace(PEM_PRIVATE_KEY_PATTERN, "***");
118
+
119
+ // Replace Authorization headers (non-Bearer format)
120
+ result = redactAuthHeader(result);
121
+
122
+ // Replace Bearer tokens
123
+ result = redactBearerTokens(result);
124
+
125
+ // Replace inline secrets: key=value or key:value patterns
126
+ result = redactInlineSecrets(result);
127
+
128
+ return result;
16
129
  }
17
130
 
18
- export function redactSecretString(value: string): string {
19
- return value
20
- .replace(PEM_PRIVATE_KEY_PATTERN, "***")
21
- .replace(AUTH_HEADER_PATTERN, "$1***")
22
- .replace(BEARER_PATTERN, "$1***")
23
- .replace(INLINE_SECRET_PATTERN, "$1$2***");
131
+ // Linear-time inline secret redaction: token=xxx, api_key=xxx, etc.
132
+ function redactInlineSecrets(value: string): string {
133
+ const result: string[] = [];
134
+ let i = 0;
135
+
136
+ while (i < value.length) {
137
+ // Look for pattern: word_chars + = or : + non-whitespace_value
138
+ // Check for secret key followed by = or :
139
+ let j = i;
140
+ let keyLen = 0;
141
+
142
+ // Collect key characters (alphanumeric, underscore, hyphen)
143
+ while (j < value.length && /[a-zA-Z0-9_-]/.test(value[j])) {
144
+ j++;
145
+ keyLen++;
146
+ }
147
+
148
+ if (keyLen > 0 && j < value.length && (value[j] === '=' || value[j] === ':')) {
149
+ const key = value.substring(i, i + keyLen);
150
+
151
+ // Check if this is a secret key
152
+ if (isSecretKey(key)) {
153
+ // Find the value (everything after = or : until space, comma, or end)
154
+ const sep = value[j];
155
+ let k = j + 1;
156
+ let valLen = 0;
157
+ while (k < value.length && valLen < 500 && value[k] !== ' ' && value[k] !== ',' && value[k] !== ';' && value[k] !== '"' && value[k] !== '"' && value[k] !== '\r' && value[k] !== '\n') {
158
+ k++;
159
+ valLen++;
160
+ }
161
+
162
+ // Only redact if there's actual content
163
+ if (valLen > 0) {
164
+ result.push(key);
165
+ result.push(sep);
166
+ result.push("***");
167
+ i = k;
168
+ continue;
169
+ }
170
+ }
171
+ }
172
+
173
+ result.push(value[i]);
174
+ i++;
175
+ }
176
+
177
+ return result.join("");
24
178
  }
25
179
 
26
180
  export function redactSecrets(value: unknown, keyName = ""): unknown {
@@ -41,4 +195,4 @@ export function redactJsonLine(line: string): string {
41
195
  } catch {
42
196
  return redactSecretString(line);
43
197
  }
44
- }
198
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Session ID utilities for pi-crew / pi session alignment.
3
+ *
4
+ * pi's session IDs use the format:
5
+ * ^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$
6
+ *
7
+ * This module provides utilities to generate valid pi session IDs
8
+ * that align with pi-crew run IDs for easy cross-referencing.
9
+ */
10
+
11
+ /**
12
+ * Validate session ID format per pi's requirements.
13
+ * Format: ^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$
14
+ */
15
+ export function assertValidSessionId(id: string): void {
16
+ if (!id || !/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/.test(id)) {
17
+ throw new Error(
18
+ `Invalid session id: must be non-empty, alphanumeric with '-', '_', '.' and start/end with alphanumeric`,
19
+ );
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Convert a pi-crew run ID to a valid pi session ID.
25
+ *
26
+ * - Strips non-alphanumeric characters
27
+ * - Lowercases
28
+ * - Prefixes with "crew-"
29
+ * - Truncates to 16 chars for safety
30
+ *
31
+ * @param runId - The pi-crew run ID (e.g., "team_20260528133725_02e05cc5480d0175")
32
+ * @returns Valid pi session ID (e.g., "crew-team20260528133")
33
+ */
34
+ export function toPiSessionId(runId: string): string {
35
+ // Strip non-alphanumeric, lowercase, prefix with "crew-"
36
+ const sanitized = runId.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
37
+ return `crew-${sanitized.slice(0, 16)}`;
38
+ }
39
+
40
+ /**
41
+ * Validate and convert a run ID to a pi session ID.
42
+ * Returns the session ID if valid, or undefined if conversion would produce invalid ID.
43
+ */
44
+ export function safeToPiSessionId(runId: string): string | undefined {
45
+ try {
46
+ const sessionId = toPiSessionId(runId);
47
+ assertValidSessionId(sessionId);
48
+ return sessionId;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
@@ -8,6 +8,9 @@ export interface ServerSentEvent {
8
8
  /** L1: Maximum number of raw lines before discarding an oversized event. */
9
9
  const MAX_EVENT_LINES = 1000;
10
10
 
11
+ /** L2: Maximum data size per line to prevent unbounded memory usage. */
12
+ const MAX_DATA_SIZE = 100000; // 100KB per line
13
+
11
14
  /** Read newline-delimited lines from a text ReadableStream, buffering partial chunks. */
12
15
  async function* readLines(
13
16
  stream: ReadableStream<string>,
@@ -97,13 +100,19 @@ export async function* readSseEvents(
97
100
 
98
101
  currentRaw.push(line);
99
102
 
100
- // L1: Guard against unbounded memory growth
103
+ // L1: Guard against unbounded memory growth (line count)
101
104
  if (currentRaw.length > MAX_EVENT_LINES) {
102
105
  const evt = flush();
103
106
  if (evt) yield evt;
104
107
  continue;
105
108
  }
106
109
 
110
+ // L2: Guard against unbounded memory growth (data size per line)
111
+ if (value.length > MAX_DATA_SIZE) {
112
+ // Truncate oversized data to prevent memory issues
113
+ value = value.slice(0, MAX_DATA_SIZE);
114
+ }
115
+
107
116
  if (field === "event") {
108
117
  currentEvent = value;
109
118
  } else if (field === "data") {
@@ -59,7 +59,12 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
59
59
  // Commit changes to a branch instead of just preserving the worktree
60
60
  try {
61
61
  execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
62
- const safeDesc = entry.name.slice(0, 200);
62
+ let safeDesc = entry.name.slice(0, 200);
63
+ // SECURITY: Strip any newlines that could be injected via a malicious worktree name
64
+ // to prevent newline injection in git commit messages
65
+ if (safeDesc.includes("\n")) {
66
+ safeDesc = safeDesc.replace(/[\r\n]+/g, " ");
67
+ }
63
68
  execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
64
69
  // Create branch in the main repo pointing to this worktree's HEAD
65
70
  try {
@@ -128,19 +128,38 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
128
128
  return [];
129
129
  }
130
130
  const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs");
131
- // On Windows, set shell:true to ensure PATH resolution and .cjs/.bat file associations work.
132
- const useShell = process.platform === "win32";
133
- const result = spawnSync(nodeHook ? process.execPath : hookPath, nodeHook ? [hookPath] : [], {
134
- cwd: worktreePath,
135
- encoding: "utf-8",
136
- input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
137
- timeout: cfg.setupHookTimeoutMs ?? 30_000,
138
- shell: useShell,
139
- env: sanitizeEnvSecrets(process.env, {
140
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
141
- }),
142
- windowsHide: true,
143
- });
131
+ // For .bat/.cmd files on Windows, execute via cmd.exe /c directly
132
+ const isBatchFile = hookPath.endsWith(".bat") || hookPath.endsWith(".cmd");
133
+ // SECURITY: Never use shell:true prevents command injection from untrusted hooks.
134
+ // Non-node, non-batch hooks on Windows will fail to execute rather than
135
+ // running through a shell that could interpret malicious filenames.
136
+ const useShell = false;
137
+ if (process.platform === "win32" && !nodeHook && !isBatchFile) {
138
+ logInternalError("worktree.setupHook.windowsNoShell", new Error("Non-node, non-batch hook skipped on Windows (shell:true disabled for security)"), `hook=${hookPath}`);
139
+ }
140
+ const result = isBatchFile
141
+ ? spawnSync("cmd.exe", ["/c", hookPath], {
142
+ cwd: worktreePath,
143
+ encoding: "utf-8",
144
+ input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
145
+ timeout: cfg.setupHookTimeoutMs ?? 30_000,
146
+ shell: false, // cmd.exe /c handles batch files safely
147
+ env: sanitizeEnvSecrets(process.env, {
148
+ allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
149
+ }),
150
+ windowsHide: true,
151
+ })
152
+ : spawnSync(nodeHook ? process.execPath : hookPath, nodeHook ? [hookPath] : [], {
153
+ cwd: worktreePath,
154
+ encoding: "utf-8",
155
+ input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
156
+ timeout: cfg.setupHookTimeoutMs ?? 30_000,
157
+ shell: useShell,
158
+ env: sanitizeEnvSecrets(process.env, {
159
+ allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
160
+ }),
161
+ windowsHide: true,
162
+ });
144
163
  if (result.error) throw new Error(`worktree setup hook failed: ${result.error.message}`);
145
164
  if (result.status !== 0) throw new Error(`worktree setup hook failed with exit code ${result.status}: ${result.stderr || result.stdout || "no output"}`);
146
165
  const trimmed = result.stdout.trim();