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
@@ -11,37 +11,314 @@ export function assertSafePathId(kind: string, value: string): string {
11
11
  }
12
12
 
13
13
  export function resolveContainedPath(baseDir: string, targetPath: string): string {
14
+ if (targetPath.includes('\0')) {
15
+ throw new Error(`Security: path contains null byte`);
16
+ }
14
17
  const base = path.resolve(baseDir);
15
18
  const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
16
- const relative = path.relative(base, resolved);
19
+ // On Windows, paths are case-insensitive and short-name (8.3) aliases may
20
+ // differ from long-name forms (e.g. C:\Users\RUNNER~1 vs C:\Users\runneradmin).
21
+ // We normalize both paths to their canonical form by resolving through
22
+ // realpathSync, walking up ancestors for non-existent paths.
23
+ const baseNorm = process.platform === "win32" ? resolveWindowsCanonical(base) : base;
24
+ const resolvedNorm = process.platform === "win32" ? resolveWindowsCanonical(resolved) : resolved;
25
+ const relative = process.platform === "win32"
26
+ ? path.relative(baseNorm.toLowerCase(), resolvedNorm.toLowerCase())
27
+ : path.relative(base, resolved);
17
28
  if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
18
29
  return resolved;
19
30
  }
20
31
 
32
+ /**
33
+ * On Windows, resolve a path to its canonical (long-name) form.
34
+ * Walks up ancestors until finding one that exists, then joins back down.
35
+ * This handles paths where intermediate directories don't exist yet but
36
+ * their ancestors do (and may use short-name aliases).
37
+ */
38
+ function resolveWindowsCanonical(p: string): string {
39
+ try {
40
+ // Use regular realpathSync (not .native) to preserve the input path form.
41
+ // On Windows CI, .native always returns long-name (runneradmin) while
42
+ // non-native preserves short-name (RUNNER~1). Using non-native ensures
43
+ // the returned form matches what os.tmpdir() and mkdtempSync produce.
44
+ let real = fs.realpathSync(p);
45
+ // Guard against NTFS internal paths (e.g. C:\$Extend\$Deleted)
46
+ if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
47
+ return real;
48
+ } catch {
49
+ // Fallback: try realpathSync (non-native) which may succeed where .native fails
50
+ try {
51
+ const real = fs.realpathSync(p);
52
+ return real;
53
+ } catch { /* proceed to ancestor walk */ }
54
+ // Walk up to find the deepest existing ancestor
55
+ const parts: string[] = [];
56
+ let current = p;
57
+ while (current !== path.dirname(current)) {
58
+ try {
59
+ // Use non-native to preserve input path form
60
+ let real = fs.realpathSync(current);
61
+ // Guard against NTFS internal paths
62
+ if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
63
+ // Found existing ancestor — join with remaining parts in reverse order
64
+ // (parts were pushed bottom-up, so iterate from last to first)
65
+ for (let i = parts.length - 1; i >= 0; i--) {
66
+ real = path.join(real, parts[i]);
67
+ }
68
+ return real;
69
+ } catch { /* keep walking */ }
70
+ parts.push(path.basename(current));
71
+ current = path.dirname(current);
72
+ }
73
+ // Couldn't resolve any ancestor — return original
74
+ return p;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Resolve a target path to its real (symlink-resolved) absolute path while
80
+ * guaranteeing the result stays inside `baseDir`.
81
+ *
82
+ * ## Security model — asymmetric ancestor handling
83
+ *
84
+ * `baseDir` and `targetPath` are validated with different policies because
85
+ * they play different roles:
86
+ *
87
+ * - **baseDir** (the container): all ancestors MUST exist and MUST NOT be
88
+ * symlinks. We refuse to operate if any component is missing or symlinked,
89
+ * because a symlinked container could point the caller outside the
90
+ * intended trust boundary (e.g. `/var/run -> /run` resolving into an
91
+ * attacker-controlled directory).
92
+ *
93
+ * - **targetPath** (the contained file): the FINAL component may be
94
+ * non-existent (for write operations creating a new file) and EXISTING
95
+ * ancestors of the target may also be missing. We DO require that any
96
+ * ancestor that DOES exist must not be a symlink — an attacker who can
97
+ * create a directory in the container must not be able to redirect the
98
+ * file being created.
99
+ *
100
+ * This asymmetry is intentional: callers that need to create a new file
101
+ * pass a non-existent targetPath. Callers that operate on an existing file
102
+ * get full symlink protection. Callers MUST NOT pass a symlinked
103
+ * intermediate component; if you need to, use `resolveContainedPath`
104
+ * instead (which only checks the resolved path, not the chain).
105
+ *
106
+ * Throws on:
107
+ * - null byte in targetPath
108
+ * - targetPath resolves outside baseDir
109
+ * - any existing ancestor (base or target) is a symlink
110
+ * - baseDir itself does not exist
111
+ *
112
+ * Returns the resolved real path on success, or the resolved (but not
113
+ * realpathed) path when the target does not exist yet.
114
+ *
115
+ * NOTE: There is a race condition window between validation and use where an
116
+ * attacker could create a directory component after validation but before the
117
+ * file is created. Callers MUST create parent directories atomically
118
+ * (e.g., mkdirSync with { recursive: true }) and use O_CREAT | O_NOFOLLOW | O_EXCL
119
+ * for atomic file creation, as atomicWriteFile does. This ensures the entire
120
+ * operation is atomic and prevents TOCTOU attacks.
121
+ */
21
122
  export function resolveRealContainedPath(baseDir: string, targetPath: string): string {
123
+ if (targetPath.includes('\0')) {
124
+ throw new Error(`Security: path contains null byte`);
125
+ }
22
126
  const resolved = resolveContainedPath(baseDir, targetPath);
127
+
128
+ // Open baseDir with O_NOFOLLOW to atomically validate no symlinks in the path.
129
+ // O_NOFOLLOW makes the open fail with ELOOP if any path component is a symlink.
130
+ let baseFd: number | undefined;
131
+ try {
132
+ baseFd = fs.openSync(baseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
133
+ } catch (error) {
134
+ const errCode = (error as NodeJS.ErrnoException).code;
135
+ if (errCode === "ENOENT") {
136
+ // baseDir doesn't exist yet — create it and retry
137
+ try {
138
+ fs.mkdirSync(baseDir, { recursive: true });
139
+ baseFd = fs.openSync(baseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
140
+ } catch (retryError) {
141
+ throw new Error(`Cannot open base directory ${baseDir}: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
142
+ }
143
+ } else if (errCode === "ELOOP") {
144
+ // On macOS, system directories like /var → /private/var contain symlinks.
145
+ // If baseDir is under such a path, resolve through realpath and retry.
146
+ if (process.platform === "darwin") {
147
+ try {
148
+ const realBaseDir = fs.realpathSync(baseDir);
149
+ if (realBaseDir !== baseDir) {
150
+ baseFd = fs.openSync(realBaseDir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
151
+ baseDir = realBaseDir; // update for later use
152
+ // Fall through to fstatSync below
153
+ }
154
+ } catch { /* throw original */ }
155
+ }
156
+ if (baseFd === undefined) throw new Error("Refusing to resolve: baseDir path contains a symlink: " + baseDir);
157
+ } else {
158
+ throw new Error(`Cannot open base directory ${baseDir}: ${error instanceof Error ? error.message : String(error)}`);
159
+ }
160
+ }
23
161
  let realBase: string;
24
- let realTarget: string;
25
162
  try {
26
- realBase = fs.realpathSync.native(baseDir);
27
- } catch (baseError) {
28
- throw new Error(`Cannot resolve real path of base directory ${baseDir}: ${baseError instanceof Error ? baseError.message : String(baseError)}`);
163
+ const stat = fs.fstatSync(baseFd);
164
+ if (!stat.isDirectory()) throw new Error(`baseDir ${baseDir} is not a directory`);
165
+ // Use regular realpathSync (not .native) to preserve input path form.
166
+ // On Windows CI, .native always returns long-name form (runneradmin)
167
+ // while non-native preserves short-name (RUNNER~1). Using non-native
168
+ // ensures the result matches what the caller passed in.
169
+ realBase = fs.realpathSync(baseDir);
170
+ } catch (error) {
171
+ // baseDir MUST exist and be resolvable for the containment guarantee to hold.
172
+ // Callers creating new directories must create baseDir atomically (e.g.,
173
+ // mkdirSync with { recursive: true }) BEFORE calling this function, and use
174
+ // O_NOFOLLOW|O_CREAT|O_EXCL for the actual file creation to ensure atomicity.
175
+ // The safe-paths validation and the file creation are two separate operations
176
+ // with a gap between them — callers must close this gap with atomic primitives.
177
+ throw new Error(`Cannot resolve real path of base directory ${baseDir}: ${error instanceof Error ? error.message : String(error)}`);
178
+ } finally {
179
+ fs.closeSync(baseFd);
180
+ }
181
+
182
+ // Walk the ancestor chain of the resolved target path, using O_NOFOLLOW
183
+ // on each ancestor to atomically validate none are symlinks.
184
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW;
185
+ const O_RDONLY = fs.constants.O_RDONLY;
186
+ const resolvedParts = resolved.split(path.sep);
187
+ let resolvedAccumulated = "";
188
+ if (resolvedParts[0] === "") resolvedAccumulated = "/"; // Unix root
189
+ for (let i = 1; i < resolvedParts.length; i++) {
190
+ if (resolvedParts[i] === "") continue;
191
+ resolvedAccumulated = path.join(resolvedAccumulated, resolvedParts[i]);
192
+ try {
193
+ const fd = fs.openSync(resolvedAccumulated, O_RDONLY | O_NOFOLLOW);
194
+ fs.closeSync(fd);
195
+ } catch (error) {
196
+ if ((error as NodeJS.ErrnoException).code === "ELOOP") {
197
+ // On macOS, /var → /private/var, /tmp → /private/tmp, /etc → /private/etc
198
+ // are system symlinks managed by the OS. Allow them.
199
+ if (process.platform === "darwin") {
200
+ const resolvedSymlink = resolvedAccumulated;
201
+ const knownDarwinSymlinks = ["/var", "/tmp", "/etc", "/private/var", "/private/tmp", "/private/etc"];
202
+ if (knownDarwinSymlinks.includes(resolvedSymlink)) continue;
203
+ }
204
+ throw new Error("Refusing to resolve: target path ancestor is a symlink: " + resolvedAccumulated);
205
+ }
206
+ // EPERM on Windows when opening a directory — skip validation
207
+ if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") continue;
208
+ // ENOENT means component doesn't exist — that's OK. Only existing symlinks
209
+ // are a security risk (symlinks to attacker-controlled targets). Non-existent
210
+ // paths can be created by the caller and don't pose a symlink risk.
211
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
212
+ // For the final component (target itself), ENOENT is expected for non-existent targets.
213
+ if (i === resolvedParts.length - 1) continue;
214
+ // For non-final components (parent directories), ENOENT is also acceptable —
215
+ // the caller will create them before the write operation if needed.
216
+ // We only need to ensure no existing path component is a symlink.
217
+ continue;
218
+ }
219
+ }
220
+
221
+ // Open the target with O_NOFOLLOW to catch any symlinks.
222
+ // ENOENT is acceptable for write operations — the file may not exist yet.
223
+ let targetFd: number;
224
+ try {
225
+ targetFd = fs.openSync(resolved, O_RDONLY | O_NOFOLLOW);
226
+ } catch (error) {
227
+ if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Refusing to resolve: target path is a symlink: " + resolved);
228
+ // EPERM on Windows when opening a directory — treat as non-existent
229
+ if ((error as NodeJS.ErrnoException).code === "EPERM" && process.platform === "win32") return resolved;
230
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
231
+ // Target doesn't exist yet — that's OK for write operations.
232
+ // All ancestors have been validated above (no symlinks).
233
+ // The caller will create the file with atomic primitives.
234
+ return resolved;
235
+ }
236
+ throw new Error(`Cannot open ${resolved}: ${error instanceof Error ? error.message : String(error)}`);
29
237
  }
238
+
239
+ let realTarget: string;
30
240
  try {
31
- realTarget = fs.realpathSync.native(resolved);
241
+ // Use regular realpathSync (not .native) to preserve input path form.
242
+ realTarget = fs.realpathSync(resolved);
32
243
  } catch (targetError) {
33
244
  if ((targetError as NodeJS.ErrnoException).code === "ENOENT") {
34
- throw new Error(`Path does not exist: ${resolved}`);
245
+ // Target doesn't exist yet this is OK for write operations.
246
+ // Return the resolved path so the caller can create the file.
247
+ // We already validated all ancestors are not symlinks above.
248
+ return resolved;
35
249
  }
36
250
  throw new Error(`Cannot resolve real path of ${resolved}: ${targetError instanceof Error ? targetError.message : String(targetError)}`);
251
+ } finally {
252
+ fs.closeSync(targetFd);
253
+ }
254
+
255
+ // Re-validate the ancestor chain of the resolved path to catch any TOCTOU
256
+ // races that occurred between the initial O_NOFOLLOW validation and the
257
+ // realpathSync call. An attacker could have replaced a validated ancestor
258
+ // with a symlink during this window.
259
+ //
260
+ // Skip the final path component (realTarget itself) — we just successfully
261
+ // realpathSync'd it, so it exists. Re-validating it can spuriously fail on
262
+ // Windows where the resolved path uses short-name (8.3) form that
263
+ // openSync cannot reopen, or where the realpathSync result differs in
264
+ // case/separator form from the original.
265
+ //
266
+ // Walk via path.dirname which is portable across all platforms and
267
+ // correctly handles extended-length (\\?\), UNC (\\server\share), and
268
+ // short-name paths on Windows without manual parsing.
269
+ let ancestor = path.dirname(realTarget);
270
+ while (ancestor && ancestor !== path.dirname(ancestor)) {
271
+ try {
272
+ const fd = fs.openSync(ancestor, O_RDONLY | O_NOFOLLOW);
273
+ fs.closeSync(fd);
274
+ } catch (error) {
275
+ const errCode = (error as NodeJS.ErrnoException).code;
276
+ if (errCode === "ELOOP") throw new Error("Refusing to resolve: TOCTOU race detected, path became a symlink: " + ancestor);
277
+ // Windows: EPERM can occur when opening system directories (e.g. C:\)
278
+ // or NTFS internal paths ($Extend/$Deleted). Skip and continue walking.
279
+ if (process.platform === "win32" && errCode === "EPERM") {
280
+ if (ancestor.includes("$Extend") || ancestor.includes("$Deleted")) {
281
+ // NTFS internal path — stop walking, we've reached the filesystem root
282
+ break;
283
+ }
284
+ // System directory — continue walking up
285
+ ancestor = path.dirname(ancestor);
286
+ continue;
287
+ }
288
+ if (errCode !== "ENOENT") throw error;
289
+ // ENOENT on an ancestor of realTarget after realpathSync is concerning
290
+ // — the path existed when we validated it but now doesn't. This could
291
+ // indicate a race or attack. For safety, treat this as an error.
292
+ throw new Error(`Cannot validate resolved path: ${ancestor} disappeared after realpathSync: ${error instanceof Error ? error.message : String(error)}`);
293
+ }
294
+ ancestor = path.dirname(ancestor);
295
+ }
296
+
297
+ // Verify the resolved real path is still within baseDir.
298
+ // On Windows, realpathSync.native may return different short/long-name forms
299
+ // for the same physical directory depending on how the path was opened.
300
+ // Use resolveWindowsCanonical (same as resolveContainedPath) to normalize
301
+ // both paths consistently before comparison.
302
+ if (process.platform === "win32") {
303
+ const normBase = resolveWindowsCanonical(realBase).replace(/\\/g, "/").toLowerCase();
304
+ const normTarget = resolveWindowsCanonical(realTarget).replace(/\\/g, "/").toLowerCase();
305
+ if (!normTarget.startsWith(normBase + "/") && normBase !== normTarget) {
306
+ throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
307
+ }
308
+ } else {
309
+ const relative = path.relative(realBase, realTarget);
310
+ if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
37
311
  }
38
- const relative = path.relative(realBase, realTarget);
39
- if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
40
312
  return realTarget;
41
313
  }
42
314
 
43
315
  export function resolveContainedRelativePath(baseDir: string, relativePath: string, kind = "path"): string {
44
- const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
316
+ if (relativePath.includes('\0')) {
317
+ throw new Error(`Security: path contains null byte: ${kind}`);
318
+ }
319
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\/+/, "");
320
+ // Detect Windows absolute paths (C:\, \\server\share) that path.isAbsolute may miss after normalization
321
+ if (/^[A-Za-z]:/.test(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
45
322
  if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
46
323
  return resolveContainedPath(baseDir, path.resolve(baseDir, normalized));
47
324
  }
@@ -5,6 +5,8 @@ import type { TeamRunManifest } from "../state/types.ts";
5
5
  import { writeArtifact } from "../state/artifact-store.ts";
6
6
  import { projectCrewRoot } from "../utils/paths.ts";
7
7
  import { DEFAULT_PATHS } from "../config/defaults.ts";
8
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
9
+ import { logInternalError } from "../utils/internal-error.ts";
8
10
 
9
11
  export interface WorktreeCleanupResult {
10
12
  removed: string[];
@@ -14,15 +16,33 @@ export interface WorktreeCleanupResult {
14
16
  committedBranches: string[];
15
17
  }
16
18
 
19
+ // SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
20
+ // Git operations do not need PI_CREW_* execution-control vars.
21
+ const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "SHELL", "TERM", "LANG", "LC_ALL", "LC_COLLATE", "LC_CTYPE", "LC_MESSAGES", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "NVM_BIN", "NVM_DIR", "NODE_PATH", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL"] }), LANG: "C", LC_ALL: "C" };
22
+
23
+ function sanitizeBranchPart(value: string): string {
24
+ return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
25
+ }
26
+
27
+ function sanitizeFilename(value: string): string {
28
+ // Strip control chars and newlines for safe artifact filenames
29
+ return value.slice(0, 200).replace(/[\x00-\x1f\x7f-\x9f\r\n]+/g, " ");
30
+ }
31
+
17
32
  function git(cwd: string, args: string[]): string {
18
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true }).trim();
33
+ try {
34
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true }).trim();
35
+ } catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ throw new Error(`git ${args.join(" ")} failed: ${message}`);
38
+ }
19
39
  }
20
40
 
21
- function isDirty(worktreePath: string): boolean {
41
+ function isDirty(worktreePath: string): boolean | "error" {
22
42
  try {
23
43
  return git(worktreePath, ["status", "--porcelain"]).trim().length > 0;
24
44
  } catch {
25
- return true;
45
+ return "error";
26
46
  }
27
47
  }
28
48
 
@@ -36,61 +56,112 @@ function captureDiff(worktreePath: string): string {
36
56
  }
37
57
 
38
58
  export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?: boolean; signal?: AbortSignal } = {}): WorktreeCleanupResult {
39
- const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, manifest.runId);
59
+ const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
60
+ const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
40
61
  const result: WorktreeCleanupResult = { removed: [], preserved: [], artifactPaths: [], committedBranches: [] };
41
62
  if (!fs.existsSync(worktreeRoot)) return result;
42
63
 
43
64
  // M3 fix: use withFileTypes to avoid race between readdirSync and statSync.
65
+ // Rely on Dirent.isDirectory() instead of a separate statSync to eliminate TOCTOU window.
44
66
  const withFileTypes = fs.readdirSync(worktreeRoot, { withFileTypes: true });
45
67
  for (const entry of withFileTypes) {
46
68
  if (options.signal?.aborted) break;
47
69
  if (!entry.isDirectory()) continue;
70
+ // Issue 1 fix: check signal before each git operation to respect abort faster
71
+ if (options.signal?.aborted) break;
48
72
  const worktreePath = path.join(worktreeRoot, entry.name);
49
- try {
50
- const stat = fs.statSync(worktreePath);
51
- if (!stat.isDirectory()) continue;
52
- } catch {
53
- // Entry deleted between readdir and stat — skip safely.
54
- continue;
55
- }
73
+ if (options.signal?.aborted) break;
56
74
  const dirty = isDirty(worktreePath);
57
- const branchName = `pi-crew/${manifest.runId}/${entry.name}`;
75
+ const branchName = `pi-crew/${manifest.runId}/${sanitizeBranchPart(entry.name)}`;
76
+ const safeBranchName = sanitizeBranchPart(entry.name);
58
77
  if (dirty) {
78
+ // Issue 1 fix: check signal before git operations
79
+ if (options.signal?.aborted) break;
59
80
  // Commit changes to a branch instead of just preserving the worktree
60
81
  try {
61
- execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
82
+ // Issue 2 fix: verify status before and after add to ensure atomicity
83
+ const statusBefore = git(worktreePath, ["status", "--porcelain"]);
84
+ execFileSync("git", ["add", "-A"], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
85
+ // Verify no unexpected changes were added (only staged the original dirty files)
86
+ const statusAfterAdd = git(worktreePath, ["status", "--porcelain"]);
87
+ if (statusAfterAdd !== statusBefore) {
88
+ // Something changed between our status check and add - abort to avoid including unexpected changes
89
+ throw new Error("worktree state changed unexpectedly during add; refusing to commit");
90
+ }
91
+ // Issue 1 fix: check signal before commit
92
+ if (options.signal?.aborted) break;
62
93
  let safeDesc = entry.name.slice(0, 200);
63
94
  // SECURITY: Strip any newlines that could be injected via a malicious worktree name
64
95
  // to prevent newline injection in git commit messages
65
96
  if (safeDesc.includes("\n")) {
66
- safeDesc = safeDesc.replace(/[\r\n]+/g, " ");
97
+ safeDesc = safeDesc.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/g, " ");
67
98
  }
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 });
99
+ execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
100
+ // Issue 1 fix: check signal before branch creation
101
+ if (options.signal?.aborted) break;
69
102
  // Create branch in the main repo pointing to this worktree's HEAD
103
+ let branchError: Error | null = null;
70
104
  try {
71
- execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
72
- } catch {
105
+ execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
106
+ } catch (err) {
107
+ branchError = err instanceof Error ? err : new Error(String(err));
73
108
  // Branch already exists — use timestamp suffix
74
109
  const tsBranch = `${branchName}-${Date.now()}`;
75
- execFileSync("git", ["branch", tsBranch], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" }, windowsHide: true });
110
+ try {
111
+ execFileSync("git", ["branch", tsBranch], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
112
+ } catch (err2) {
113
+ // Both branch attempts failed — accumulate error for outer catch
114
+ const err2_msg = err2 instanceof Error ? err2.message : String(err2);
115
+ throw new Error(`branch creation failed (${branchName} already exists): ${branchError.message}; fallback branch also failed: ${err2_msg}`);
116
+ }
76
117
  }
77
118
  result.committedBranches.push(branchName);
78
- // Remove the worktree (branch persists)
119
+ // Issue 1 fix: check signal before worktree remove
120
+ if (options.signal?.aborted) break;
121
+ // Remove the worktree (branch persists).
122
+ // NOTE: If git worktree remove fails here after the commit succeeded,
123
+ // the worktree directory remains on disk orphaned from the branch.
124
+ // The committed changes are safe in the branch and recoverable via:
125
+ // git branch -D <branchName> (to clean up the branch)
126
+ // rm -rf <worktreePath> (to clean up the orphaned directory)
79
127
  const removeArgs = ["worktree", "remove", "--force", worktreePath];
80
- git(manifest.cwd, removeArgs);
81
- result.removed.push(worktreePath);
128
+ // Capture git status before removal since worktree won't exist after successful remove
129
+ const gitStatusBeforeRemove = git(worktreePath, ["status", "--porcelain"]);
130
+ try {
131
+ git(manifest.cwd, removeArgs);
132
+ result.removed.push(worktreePath);
133
+ } catch (removeError) {
134
+ // Commit succeeded but worktree remove failed — directory is orphaned
135
+ // Issue 1 fix: use fs.rmSync as fallback to clean up orphaned directory
136
+ try {
137
+ fs.rmSync(worktreePath, { recursive: true, force: true });
138
+ result.removed.push(worktreePath);
139
+ } catch {
140
+ result.preserved.push({ path: worktreePath, reason: `commit succeeded but worktree remove failed: ${removeError instanceof Error ? removeError.message : String(removeError)}; fs.rmSync fallback also failed` });
141
+ }
142
+ const artifact = writeArtifact(manifest.artifactsRoot, {
143
+ kind: "metadata",
144
+ relativePath: `metadata/worktree-branch-${safeBranchName}.json`,
145
+ content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}`, gitStatusAtCommit: gitStatusBeforeRemove }, null, 2),
146
+ producer: "worktree-cleanup",
147
+ });
148
+ result.artifactPaths.push(artifact.path);
149
+ continue;
150
+ }
82
151
  const artifact = writeArtifact(manifest.artifactsRoot, {
83
152
  kind: "metadata",
84
- relativePath: `metadata/worktree-branch-${entry.name}.json`,
85
- content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}` }, null, 2),
153
+ relativePath: `metadata/worktree-branch-${safeBranchName}.json`,
154
+ content: JSON.stringify({ worktreePath, branch: branchName, committedAt: new Date().toISOString(), mergeCommand: `git merge ${branchName}`, gitStatusAtCommit: gitStatusBeforeRemove }, null, 2),
86
155
  producer: "worktree-cleanup",
87
156
  });
88
157
  result.artifactPaths.push(artifact.path);
89
158
  } catch (error) {
90
159
  // Fallback to preserving dirty worktree
160
+ // FIX: entry is a DirEnt object, must use entry.name
161
+ const safeFallbackName = sanitizeFilename(entry.name);
91
162
  const artifact = writeArtifact(manifest.artifactsRoot, {
92
163
  kind: "diff",
93
- relativePath: `cleanup/${entry}.diff`,
164
+ relativePath: `cleanup/${safeFallbackName}.diff`,
94
165
  content: captureDiff(worktreePath),
95
166
  producer: "worktree-cleanup",
96
167
  });
@@ -99,6 +170,10 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
99
170
  }
100
171
  continue;
101
172
  }
173
+ // Issue 3 fix: sanitize entry.name before using in git commands
174
+ const safeEntryName = sanitizeBranchPart(entry.name);
175
+ // Issue 1 fix: check signal before non-dirty worktree remove
176
+ if (options.signal?.aborted) break;
102
177
  const args = ["worktree", "remove"];
103
178
  if (options.force) args.push("--force");
104
179
  args.push(worktreePath);
@@ -112,9 +187,25 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
112
187
  }
113
188
 
114
189
  try {
115
- if (fs.existsSync(worktreeRoot) && fs.readdirSync(worktreeRoot).length === 0) fs.rmSync(worktreeRoot, { recursive: true, force: true });
116
- } catch {
190
+ // Issue 2 fix: run git worktree prune to clean up any orphaned worktree references
191
+ // after processing all worktrees. This helps clean up directories left orphaned
192
+ // when git worktree remove failed after a successful commit.
193
+ git(manifest.cwd, ["worktree", "prune"]);
194
+ } catch (error) {
117
195
  // Non-critical cleanup.
196
+ logInternalError("cleanup.worktreePrune", error instanceof Error ? error : new Error(String(error)));
197
+ }
198
+ try {
199
+ // Issue 1 fix: Use rmdirSync which fails atomically on non-empty directories,
200
+ // avoiding TOCTOU race between readdirSync check and rmSync removal.
201
+ // Ignore ENOENT (already removed) and ENOTEMPTY (became non-empty between check and remove).
202
+ fs.rmdirSync(worktreeRoot);
203
+ } catch (error) {
204
+ // Non-critical cleanup. Ignore ENOENT and ENOTEMPTY.
205
+ const code = (error as NodeJS.ErrnoException).code;
206
+ if (code !== "ENOENT" && code !== "ENOTEMPTY") {
207
+ // Optionally log unexpected errors in debug mode
208
+ }
118
209
  }
119
210
  return result;
120
211
  }