pi-crew 0.6.1 → 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 (102) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +70 -33
  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/config/config.ts +106 -15
  7. package/src/errors.ts +107 -0
  8. package/src/extension/async-notifier.ts +6 -2
  9. package/src/extension/crew-cleanup.ts +8 -5
  10. package/src/extension/management.ts +464 -109
  11. package/src/extension/register.ts +194 -34
  12. package/src/extension/registration/commands.ts +2 -2
  13. package/src/extension/registration/subagent-helpers.ts +2 -2
  14. package/src/extension/registration/subagent-tools.ts +1 -1
  15. package/src/extension/registration/team-tool.ts +1 -1
  16. package/src/extension/registration/viewers.ts +2 -2
  17. package/src/extension/team-tool/api.ts +3 -3
  18. package/src/extension/team-tool/cancel.ts +3 -3
  19. package/src/extension/team-tool/explain.ts +1 -1
  20. package/src/extension/team-tool/handle-schedule.ts +40 -0
  21. package/src/extension/team-tool/inspect.ts +3 -3
  22. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  23. package/src/extension/team-tool/respond.ts +2 -2
  24. package/src/extension/team-tool/run.ts +60 -11
  25. package/src/extension/team-tool/status.ts +1 -1
  26. package/src/extension/team-tool.ts +173 -46
  27. package/src/hooks/registry.ts +77 -13
  28. package/src/hooks/types.ts +9 -0
  29. package/src/plugins/plugin-define.ts +6 -0
  30. package/src/plugins/plugin-registry.ts +32 -0
  31. package/src/plugins/plugins/index.ts +3 -0
  32. package/src/plugins/plugins/nextjs.ts +19 -0
  33. package/src/plugins/plugins/vite.ts +14 -0
  34. package/src/plugins/plugins/vitest.ts +14 -0
  35. package/src/runtime/async-runner.ts +35 -7
  36. package/src/runtime/background-runner.ts +529 -144
  37. package/src/runtime/chain-parser.ts +5 -1
  38. package/src/runtime/chain-runner.ts +9 -3
  39. package/src/runtime/checkpoint.ts +262 -180
  40. package/src/runtime/child-pi.ts +164 -37
  41. package/src/runtime/crash-recovery.ts +25 -14
  42. package/src/runtime/crew-agent-records.ts +2 -0
  43. package/src/runtime/diagnostic-export.ts +1 -1
  44. package/src/runtime/dynamic-script-runner.ts +10 -7
  45. package/src/runtime/foreground-watchdog.ts +1 -1
  46. package/src/runtime/heartbeat-watcher.ts +19 -2
  47. package/src/runtime/intercom-bridge.ts +7 -0
  48. package/src/runtime/iteration-hooks.ts +1 -1
  49. package/src/runtime/manifest-cache.ts +4 -0
  50. package/src/runtime/orphan-worker-registry.ts +444 -0
  51. package/src/runtime/parent-guard.ts +70 -16
  52. package/src/runtime/pi-args.ts +437 -14
  53. package/src/runtime/pi-spawn.ts +1 -0
  54. package/src/runtime/post-checks.ts +1 -1
  55. package/src/runtime/retry-runner.ts +9 -3
  56. package/src/runtime/run-tracker.ts +38 -10
  57. package/src/runtime/sandbox.ts +17 -2
  58. package/src/runtime/scheduler.ts +25 -2
  59. package/src/runtime/skill-effectiveness.ts +105 -62
  60. package/src/runtime/skill-instructions.ts +110 -32
  61. package/src/runtime/stale-reconciler.ts +310 -69
  62. package/src/runtime/subagent-manager.ts +251 -57
  63. package/src/runtime/task-health.ts +76 -0
  64. package/src/runtime/task-id.ts +20 -0
  65. package/src/runtime/task-runner/live-executor.ts +1 -1
  66. package/src/runtime/task-runner/progress.ts +1 -1
  67. package/src/runtime/task-runner/state-helpers.ts +110 -6
  68. package/src/runtime/task-runner.ts +92 -70
  69. package/src/runtime/team-runner.ts +186 -20
  70. package/src/schema/team-tool-schema.ts +27 -9
  71. package/src/skills/discover-skills.ts +9 -3
  72. package/src/state/active-run-registry.ts +170 -38
  73. package/src/state/artifact-store.ts +25 -13
  74. package/src/state/atomic-write-v2.ts +86 -0
  75. package/src/state/atomic-write.ts +346 -55
  76. package/src/state/blob-store.ts +178 -10
  77. package/src/state/crew-init.ts +161 -28
  78. package/src/state/decision-ledger.ts +172 -111
  79. package/src/state/event-log-rotation.ts +82 -52
  80. package/src/state/event-log.ts +254 -70
  81. package/src/state/health-store.ts +71 -0
  82. package/src/state/locks.ts +102 -20
  83. package/src/state/mailbox.ts +45 -7
  84. package/src/state/observation-store.ts +4 -1
  85. package/src/state/run-graph.ts +141 -130
  86. package/src/state/run-metrics.ts +24 -8
  87. package/src/state/state-store.ts +330 -43
  88. package/src/ui/live-run-sidebar.ts +1 -1
  89. package/src/ui/loaders.ts +4 -0
  90. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  91. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  92. package/src/ui/run-action-dispatcher.ts +2 -2
  93. package/src/ui/run-snapshot-cache.ts +1 -1
  94. package/src/utils/env-filter.ts +66 -0
  95. package/src/utils/file-coalescer.ts +9 -1
  96. package/src/utils/paths.ts +80 -5
  97. package/src/utils/redaction.ts +6 -1
  98. package/src/utils/safe-paths.ts +281 -10
  99. package/src/worktree/cleanup.ts +112 -24
  100. package/src/worktree/worktree-manager.ts +128 -15
  101. package/test-bugs-all.mjs +10 -6
  102. package/test-integration-check.ts +4 -0
@@ -6,6 +6,7 @@ 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
8
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
9
+ import { logInternalError } from "../utils/internal-error.ts";
9
10
 
10
11
  export interface WorktreeCleanupResult {
11
12
  removed: string[];
@@ -15,17 +16,33 @@ export interface WorktreeCleanupResult {
15
16
  committedBranches: string[];
16
17
  }
17
18
 
18
- 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", "PI_*", "PI_CREW_*"] }), LANG: "C", LC_ALL: "C" };
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
+ }
19
31
 
20
32
  function git(cwd: string, args: string[]): string {
21
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, 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
+ }
22
39
  }
23
40
 
24
- function isDirty(worktreePath: string): boolean {
41
+ function isDirty(worktreePath: string): boolean | "error" {
25
42
  try {
26
43
  return git(worktreePath, ["status", "--porcelain"]).trim().length > 0;
27
44
  } catch {
28
- return true;
45
+ return "error";
29
46
  }
30
47
  }
31
48
 
@@ -39,61 +56,112 @@ function captureDiff(worktreePath: string): string {
39
56
  }
40
57
 
41
58
  export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?: boolean; signal?: AbortSignal } = {}): WorktreeCleanupResult {
42
- 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);
43
61
  const result: WorktreeCleanupResult = { removed: [], preserved: [], artifactPaths: [], committedBranches: [] };
44
62
  if (!fs.existsSync(worktreeRoot)) return result;
45
63
 
46
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.
47
66
  const withFileTypes = fs.readdirSync(worktreeRoot, { withFileTypes: true });
48
67
  for (const entry of withFileTypes) {
49
68
  if (options.signal?.aborted) break;
50
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;
51
72
  const worktreePath = path.join(worktreeRoot, entry.name);
52
- try {
53
- const stat = fs.statSync(worktreePath);
54
- if (!stat.isDirectory()) continue;
55
- } catch {
56
- // Entry deleted between readdir and stat — skip safely.
57
- continue;
58
- }
73
+ if (options.signal?.aborted) break;
59
74
  const dirty = isDirty(worktreePath);
60
- const branchName = `pi-crew/${manifest.runId}/${entry.name}`;
75
+ const branchName = `pi-crew/${manifest.runId}/${sanitizeBranchPart(entry.name)}`;
76
+ const safeBranchName = sanitizeBranchPart(entry.name);
61
77
  if (dirty) {
78
+ // Issue 1 fix: check signal before git operations
79
+ if (options.signal?.aborted) break;
62
80
  // Commit changes to a branch instead of just preserving the worktree
63
81
  try {
82
+ // Issue 2 fix: verify status before and after add to ensure atomicity
83
+ const statusBefore = git(worktreePath, ["status", "--porcelain"]);
64
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;
65
93
  let safeDesc = entry.name.slice(0, 200);
66
94
  // SECURITY: Strip any newlines that could be injected via a malicious worktree name
67
95
  // to prevent newline injection in git commit messages
68
96
  if (safeDesc.includes("\n")) {
69
- safeDesc = safeDesc.replace(/[\r\n]+/g, " ");
97
+ safeDesc = safeDesc.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/g, " ");
70
98
  }
71
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;
72
102
  // Create branch in the main repo pointing to this worktree's HEAD
103
+ let branchError: Error | null = null;
73
104
  try {
74
105
  execFileSync("git", ["branch", branchName], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, windowsHide: true });
75
- } catch {
106
+ } catch (err) {
107
+ branchError = err instanceof Error ? err : new Error(String(err));
76
108
  // Branch already exists — use timestamp suffix
77
109
  const tsBranch = `${branchName}-${Date.now()}`;
78
- execFileSync("git", ["branch", tsBranch], { cwd: worktreePath, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: GIT_SAFE_ENV, 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
+ }
79
117
  }
80
118
  result.committedBranches.push(branchName);
81
- // 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)
82
127
  const removeArgs = ["worktree", "remove", "--force", worktreePath];
83
- git(manifest.cwd, removeArgs);
84
- 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
+ }
85
151
  const artifact = writeArtifact(manifest.artifactsRoot, {
86
152
  kind: "metadata",
87
- relativePath: `metadata/worktree-branch-${entry.name}.json`,
88
- 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),
89
155
  producer: "worktree-cleanup",
90
156
  });
91
157
  result.artifactPaths.push(artifact.path);
92
158
  } catch (error) {
93
159
  // Fallback to preserving dirty worktree
160
+ // FIX: entry is a DirEnt object, must use entry.name
161
+ const safeFallbackName = sanitizeFilename(entry.name);
94
162
  const artifact = writeArtifact(manifest.artifactsRoot, {
95
163
  kind: "diff",
96
- relativePath: `cleanup/${entry}.diff`,
164
+ relativePath: `cleanup/${safeFallbackName}.diff`,
97
165
  content: captureDiff(worktreePath),
98
166
  producer: "worktree-cleanup",
99
167
  });
@@ -102,6 +170,10 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
102
170
  }
103
171
  continue;
104
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;
105
177
  const args = ["worktree", "remove"];
106
178
  if (options.force) args.push("--force");
107
179
  args.push(worktreePath);
@@ -115,9 +187,25 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
115
187
  }
116
188
 
117
189
  try {
118
- if (fs.existsSync(worktreeRoot) && fs.readdirSync(worktreeRoot).length === 0) fs.rmSync(worktreeRoot, { recursive: true, force: true });
119
- } 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) {
120
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
+ }
121
209
  }
122
210
  return result;
123
211
  }
@@ -25,11 +25,16 @@ export interface WorktreeDiffStat {
25
25
  }
26
26
 
27
27
  function git(cwd: string, args: string[]): string {
28
- return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], 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", "PI_*", "PI_CREW_*"] }), LANG: "C", LC_ALL: "C" }, windowsHide: true }).trim();
28
+ // SECURITY: PI_* and PI_CREW_* wildcards removed they could match secret vars like PI_PASSWORD.
29
+ // Git operations do not need PI_CREW_* execution-control vars.
30
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], 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: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }, windowsHide: true }).trim();
29
31
  }
30
32
 
33
+ // Dots are removed from branch names since they are used in path construction,
34
+ // and dots could cause ambiguity with relative path handling on some platforms.
35
+ // Branch names themselves support dots in git, but we strip them for safe path use.
31
36
  function sanitizeBranchPart(value: string): string {
32
- return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
37
+ return value.toLowerCase().replace(/[^a-z0-9_/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
33
38
  }
34
39
 
35
40
  export function findGitRoot(cwd: string): string {
@@ -64,7 +69,7 @@ function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boole
64
69
  function normalizeSyntheticPath(worktreePath: string, rawPath: string): string {
65
70
  const resolved = path.resolve(worktreePath, rawPath);
66
71
  const relative = path.relative(worktreePath, resolved);
67
- if (!relative || relative === "." || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`synthetic path escapes worktree: ${rawPath}`);
72
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`synthetic path escapes worktree: ${rawPath}`);
68
73
  return path.normalize(relative);
69
74
  }
70
75
 
@@ -118,6 +123,12 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
118
123
  }
119
124
  // SECURITY WARNING: Home directory hooks (~/.pi/hooks/) are user-writable and not project-scoped.
120
125
  // A rogue npm postinstall script could place malicious hooks there. Log for visibility.
126
+ //
127
+ // SECURITY ASSUMPTION: This function trusts that the hook scripts themselves are not malicious.
128
+ // Hook scripts are executed with the same privileges as the Pi process. The caller is responsible
129
+ // for ensuring that only trusted hook scripts are configured. Path containment validation
130
+ // (isAllowedSetupHook, isHookPathContainedInRepoRoot) prevents hook scripts from writing outside
131
+ // the worktree, but cannot prevent a trusted hook from performing harmful operations within it.
121
132
  if (path.isAbsolute(rawHookPath)) {
122
133
  logInternalError("worktree.setupHook.homeHook", new Error("Home directory hook used — ensure ~/.pi/hooks/ is trusted"), `hookPath=${rawHookPath}`);
123
134
  }
@@ -128,8 +139,14 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
128
139
  logInternalError("worktree.setupHook.contained", new Error("hook path escapes repoRoot after realpath resolution: " + hookPath), `repoRoot=${repoRoot}`);
129
140
  return [];
130
141
  }
131
- if (!fs.existsSync(hookPath) || fs.statSync(hookPath).isDirectory()) {
132
- logInternalError("worktree.setupHook.missing", new Error("hook not found or is directory: " + hookPath), `cwd=${manifest.cwd}`);
142
+ try {
143
+ const hookStat = fs.lstatSync(hookPath);
144
+ if (!hookStat.isFile()) {
145
+ logInternalError("worktree.setupHook.missing", new Error("hook not found or is directory: " + hookPath), `cwd=${manifest.cwd}`);
146
+ return [];
147
+ }
148
+ } catch {
149
+ logInternalError("worktree.setupHook.missing", new Error("hook not found: " + hookPath), `cwd=${manifest.cwd}`);
133
150
  return [];
134
151
  }
135
152
  const nodeHook = hookPath.endsWith(".js") || hookPath.endsWith(".cjs") || hookPath.endsWith(".mjs");
@@ -142,26 +159,41 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
142
159
  if (process.platform === "win32" && !nodeHook && !isBatchFile) {
143
160
  logInternalError("worktree.setupHook.windowsNoShell", new Error("Non-node, non-batch hook skipped on Windows (shell:true disabled for security)"), `hook=${hookPath}`);
144
161
  }
162
+ // SECURITY: Resolve the hook to its real path before execution to close the TOCTOU window.
163
+ // This prevents a symlink swap between the containment check and actual execution.
164
+ // KNOWN LIMITATION: There is a residual TOCTOU window between realpathSync validation
165
+ // (line 166) and spawnSync execution (line 183). A sufficiently fast attacker could
166
+ // theoretically swap the symlink between these two operations. The realpathSync + O_NOFOLLOW
167
+ // approach minimizes but does not eliminate this window. To fully close it, consider
168
+ // opening the hook file once via a file descriptor and executing via fd passing (not available
169
+ // in Node.js spawnSync). This is documented as a known limitation rather than a bug.
170
+ let realHookPath: string;
171
+ try {
172
+ realHookPath = fs.realpathSync(hookPath);
173
+ } catch {
174
+ logInternalError("worktree.setupHook.realpath", new Error("hook realpath resolution failed: " + hookPath), `cwd=${manifest.cwd}`);
175
+ return [];
176
+ }
145
177
  const result = isBatchFile
146
- ? spawnSync("cmd.exe", ["/c", hookPath], {
178
+ ? spawnSync("cmd.exe", ["/c", realHookPath], {
147
179
  cwd: worktreePath,
148
180
  encoding: "utf-8",
149
181
  input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
150
182
  timeout: cfg.setupHookTimeoutMs ?? 30_000,
151
183
  shell: false, // cmd.exe /c handles batch files safely
152
184
  env: sanitizeEnvSecrets(process.env, {
153
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
185
+ allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
154
186
  }),
155
187
  windowsHide: true,
156
188
  })
157
- : spawnSync(nodeHook ? process.execPath : hookPath, nodeHook ? [hookPath] : [], {
189
+ : spawnSync(nodeHook ? process.execPath : realHookPath, nodeHook ? [realHookPath] : [], {
158
190
  cwd: worktreePath,
159
191
  encoding: "utf-8",
160
192
  input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
161
193
  timeout: cfg.setupHookTimeoutMs ?? 30_000,
162
- shell: useShell,
194
+ shell: false,
163
195
  env: sanitizeEnvSecrets(process.env, {
164
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
196
+ allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
165
197
  }),
166
198
  windowsHide: true,
167
199
  });
@@ -226,6 +258,24 @@ export function normalizeSeedPaths(seedPaths: string[], repoRoot: string): strin
226
258
  throw new Error(`seedPaths entries must stay inside repoRoot: ${entry}`);
227
259
  }
228
260
 
261
+ // Reject symlinks to prevent escape via symlink-based path traversal.
262
+ // This check is also performed in overlaySeedPaths for defense-in-depth.
263
+ // ENOENT is acceptable — seed paths may reference files that don't exist yet
264
+ // (they are validated at copy time by overlaySeedPaths).
265
+ try {
266
+ const stat = fs.lstatSync(absolutePath);
267
+ if (stat.isSymbolicLink()) {
268
+ throw new Error(`seedPaths entries cannot be symlinks: ${entry}`);
269
+ }
270
+ } catch (error) {
271
+ if (error instanceof Error && error.message.startsWith("seedPaths entries")) throw error;
272
+ // ENOENT is acceptable — seed paths may reference files that don't exist yet.
273
+ // Skip symlink check but still include the path in results.
274
+ if (!(error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT")) {
275
+ throw new Error(`seedPaths entries must be accessible: ${entry}`);
276
+ }
277
+ }
278
+
229
279
  const normalizedPath = relativePath.split(path.sep).join("/");
230
280
  if (seen.has(normalizedPath)) continue;
231
281
  seen.add(normalizedPath);
@@ -247,15 +297,27 @@ export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPat
247
297
  const sourcePath = path.join(repoRoot, seedPath);
248
298
  const destinationPath = path.join(worktreePath, seedPath);
249
299
 
250
- if (!fs.existsSync(sourcePath)) {
300
+ let sourceStat: fs.Stats;
301
+ try {
302
+ sourceStat = fs.lstatSync(sourcePath);
303
+ } catch {
251
304
  logInternalError("worktree.seedPaths.missing", new Error(`Seed path does not exist: ${seedPath}`));
252
305
  continue;
253
306
  }
307
+ // Reject symlinks in seed paths to prevent copying symlinks into worktree (which could point outside repoRoot).
308
+ if (sourceStat.isSymbolicLink()) {
309
+ logInternalError("worktree.seedPaths.symlink", new Error(`Seed path is a symlink — rejected: ${seedPath}`));
310
+ continue;
311
+ }
312
+ if (!sourceStat.isFile() && !sourceStat.isDirectory()) {
313
+ logInternalError("worktree.seedPaths.invalid", new Error(`Seed path is neither file nor directory: ${seedPath}`));
314
+ continue;
315
+ }
254
316
 
255
317
  fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
256
318
  fs.rmSync(destinationPath, { force: true, recursive: true });
257
319
  fs.cpSync(sourcePath, destinationPath, {
258
- dereference: false,
320
+ dereference: true,
259
321
  force: true,
260
322
  preserveTimestamps: true,
261
323
  recursive: true,
@@ -268,11 +330,44 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
268
330
  const repoRoot = findGitRoot(manifest.cwd);
269
331
  const loadedConfig = loadConfig(manifest.cwd);
270
332
  if (loadedConfig.config.requireCleanWorktreeLeader !== false) assertCleanLeader(repoRoot);
271
- const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, manifest.runId);
333
+ const sanitizedRunId = manifest.runId.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/^-+|-+$/g, "") || "run";
334
+ const worktreeRoot = path.join(projectCrewRoot(manifest.cwd), DEFAULT_PATHS.state.worktreesSubdir, sanitizedRunId);
272
335
  fs.mkdirSync(worktreeRoot, { recursive: true });
273
- const worktreePath = path.join(worktreeRoot, task.id);
336
+ // Resolve through realpathSync.native to get long-name form on Windows.
337
+ // git worktree uses long-name paths, so we must match. If .native fails,
338
+ // fall back to non-native (preserves input form).
339
+ let resolvedWorktreeRoot = worktreeRoot;
340
+ try {
341
+ const r = fs.realpathSync.native(worktreeRoot);
342
+ resolvedWorktreeRoot = r.startsWith("\\\\?\\") ? r.slice(4) : r;
343
+ } catch {
344
+ try { resolvedWorktreeRoot = fs.realpathSync(worktreeRoot); } catch { /* keep as-is */ }
345
+ }
346
+ const sanitizedTaskId = sanitizeBranchPart(task.id);
347
+ const worktreePath = path.join(resolvedWorktreeRoot, sanitizedTaskId);
274
348
  const branch = `pi-crew/${sanitizeBranchPart(manifest.runId)}/${sanitizeBranchPart(task.id)}`;
275
- if (fs.existsSync(worktreePath)) {
349
+ // Use `git worktree list --porcelain` to atomically verify the worktree exists.
350
+ // This avoids a TOCTOU race between fs.existsSync and git branch verification.
351
+ let worktreeExists = false;
352
+ try {
353
+ const worktreeList = git(repoRoot, ["worktree", "list", "--porcelain"]);
354
+ // `git worktree list --porcelain` outputs "worktree /path" per entry.
355
+ // We must compare against the path part (after "worktree ").
356
+ // On Windows, git may return forward-slash long-name paths while
357
+ // worktreePath uses short-name backslash form. Resolve both through
358
+ // realpathSync.native (which always returns long-name on Windows)
359
+ // for consistent comparison.
360
+ const normalizedWtPath = process.platform === "win32" ? (() => { try { const r = fs.realpathSync.native(worktreePath); return r.startsWith("\\\\?\\") ? r.slice(4) : r; } catch { return worktreePath; } })().replace(/\\/g, "/").toLowerCase() : worktreePath;
361
+ worktreeExists = worktreeList.split("\n").some((line) => {
362
+ const trimmed = line.trim();
363
+ const matchPath = trimmed.startsWith("worktree ") ? trimmed.slice(9) : trimmed;
364
+ if (process.platform === "win32") {
365
+ return matchPath.replace(/\\/g, "/").toLowerCase() === normalizedWtPath;
366
+ }
367
+ return matchPath === worktreePath;
368
+ });
369
+ } catch { worktreeExists = false; }
370
+ if (worktreeExists) {
276
371
  let currentBranch: string;
277
372
  try {
278
373
  currentBranch = git(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -282,16 +377,27 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
282
377
  if (currentBranch !== branch) {
283
378
  throw new Error(`Existing worktree branch mismatch at ${worktreePath}: expected '${branch}', got '${currentBranch}'.`);
284
379
  }
380
+ // Check for uncommitted changes from previous run before reusing
381
+ const dirtyStatus = git(worktreePath, ["status", "--porcelain"]);
382
+ if (dirtyStatus.trim()) {
383
+ // Discard uncommitted changes to ensure clean slate for new task
384
+ logInternalError("worktree.reused.dirty", new Error(`Discarding uncommitted changes in reused worktree at ${worktreePath}`), `runId=${manifest.runId}, taskId=${task.id}, dirtyStatus=${dirtyStatus.trim()}`);
385
+ git(worktreePath, ["checkout", "--", "."]);
386
+ git(worktreePath, ["clean", "-fd"]);
387
+ }
285
388
  // Overlay seed paths from config + step-level seedPaths (reused worktree)
286
389
  const globalSeedPaths = loadedConfig.config.worktree?.seedPaths ?? [];
287
390
  const mergedReused = normalizeSeedPaths([...globalSeedPaths, ...(stepSeedPaths ?? [])], repoRoot);
288
391
  if (mergedReused.length > 0) {
289
392
  overlaySeedPaths(repoRoot, worktreePath, mergedReused);
290
393
  }
394
+ // Re-validate leader is still clean before reusing — leader state may have changed since first preparation
395
+ assertCleanLeader(repoRoot);
291
396
  return { cwd: worktreePath, worktreePath, branch, reused: true };
292
397
  }
293
398
  pruneStaleWorktrees(repoRoot);
294
399
  const exists = branchExists(repoRoot, branch);
400
+ let worktreeCreated = false;
295
401
  try {
296
402
  if (exists.local) {
297
403
  git(repoRoot, ["worktree", "add", worktreePath, branch]);
@@ -301,7 +407,14 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
301
407
  }
302
408
  git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
303
409
  }
410
+ worktreeCreated = true;
304
411
  } catch (error) {
412
+ // Clean up orphaned worktree directory if git worktree add failed
413
+ if (fs.existsSync(worktreePath)) {
414
+ try {
415
+ fs.rmSync(worktreePath, { recursive: true, force: true });
416
+ } catch { /* best-effort cleanup */ }
417
+ }
305
418
  const msg = error instanceof Error ? error.message : String(error);
306
419
  if (/already checked out|is already used by worktree/i.test(msg)) {
307
420
  throw new Error(`Branch '${branch}' is checked out at another worktree. Run \`team cleanup runId=${manifest.runId} force=true\` or manually remove the conflicting worktree.`);
package/test-bugs-all.mjs CHANGED
@@ -69,13 +69,17 @@ if (completedIdsFix) {
69
69
 
70
70
  // Check dist file
71
71
  console.log("\n=== Checking dist/index.mjs ===");
72
- const distContent = fs.readFileSync("dist/index.mjs", "utf-8");
73
- const distNeedsAttention = distContent.includes('t2.status === "completed" || t2.status === "needs_attention"');
74
- if (distNeedsAttention) {
75
- console.log(" ✅ Bug #20 fix is in dist/index.mjs");
72
+ if (fs.existsSync("dist/index.mjs")) {
73
+ const distContent = fs.readFileSync("dist/index.mjs", "utf-8");
74
+ const distNeedsAttention = distContent.includes('t2.status === "completed" || t2.status === "needs_attention"');
75
+ if (distNeedsAttention) {
76
+ console.log(" ✅ Bug #20 fix is in dist/index.mjs");
77
+ } else {
78
+ console.log(" ❌ Bug #20 fix NOT in dist/index.mjs - rebuild needed");
79
+ allPassed = false;
80
+ }
76
81
  } else {
77
- console.log(" ❌ Bug #20 fix NOT in dist/index.mjs - rebuild needed");
78
- allPassed = false;
82
+ console.log(" ⚠️ dist/index.mjs not found - run npm run build first");
79
83
  }
80
84
 
81
85
  console.log("\n" + "=".repeat(40));
@@ -65,7 +65,9 @@ test("fast-fix team run completes successfully with mock child Pi", async () =>
65
65
 
66
66
  const prevExec = process.env.PI_TEAMS_EXECUTE_WORKERS;
67
67
  const prevMock = process.env.PI_TEAMS_MOCK_CHILD_PI;
68
+ const prevAllow = process.env.PI_CREW_ALLOW_MOCK;
68
69
  process.env.PI_TEAMS_EXECUTE_WORKERS = "1";
70
+ process.env.PI_CREW_ALLOW_MOCK = "1";
69
71
  process.env.PI_TEAMS_MOCK_CHILD_PI = "success";
70
72
 
71
73
  try {
@@ -109,6 +111,8 @@ test("fast-fix team run completes successfully with mock child Pi", async () =>
109
111
  else process.env.PI_TEAMS_EXECUTE_WORKERS = prevExec;
110
112
  if (prevMock === undefined) delete process.env.PI_TEAMS_MOCK_CHILD_PI;
111
113
  else process.env.PI_TEAMS_MOCK_CHILD_PI = prevMock;
114
+ if (prevAllow === undefined) delete process.env.PI_CREW_ALLOW_MOCK;
115
+ else process.env.PI_CREW_ALLOW_MOCK = prevAllow;
112
116
  fs.rmSync(cwd, { recursive: true, force: true });
113
117
  }
114
118
  });