pi-crew 0.6.1 → 0.6.4

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 (119) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +81 -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/docs/ui-optimization-plan.md +447 -0
  6. package/package.json +2 -2
  7. package/src/config/config.ts +106 -15
  8. package/src/errors.ts +107 -0
  9. package/src/extension/async-notifier.ts +6 -2
  10. package/src/extension/crew-cleanup.ts +8 -5
  11. package/src/extension/management.ts +464 -109
  12. package/src/extension/register.ts +213 -35
  13. package/src/extension/registration/brief-tool-overrides.ts +400 -0
  14. package/src/extension/registration/commands.ts +27 -2
  15. package/src/extension/registration/subagent-helpers.ts +2 -2
  16. package/src/extension/registration/subagent-tools.ts +9 -4
  17. package/src/extension/registration/team-tool.ts +17 -10
  18. package/src/extension/registration/viewers.ts +2 -2
  19. package/src/extension/team-tool/api.ts +3 -3
  20. package/src/extension/team-tool/cancel.ts +3 -3
  21. package/src/extension/team-tool/explain.ts +1 -1
  22. package/src/extension/team-tool/handle-schedule.ts +40 -0
  23. package/src/extension/team-tool/inspect.ts +3 -3
  24. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  25. package/src/extension/team-tool/respond.ts +2 -2
  26. package/src/extension/team-tool/run.ts +64 -14
  27. package/src/extension/team-tool/status.ts +1 -1
  28. package/src/extension/team-tool-types.ts +2 -0
  29. package/src/extension/team-tool.ts +173 -46
  30. package/src/hooks/registry.ts +77 -13
  31. package/src/hooks/types.ts +9 -0
  32. package/src/plugins/plugin-define.ts +6 -0
  33. package/src/plugins/plugin-registry.ts +32 -0
  34. package/src/plugins/plugins/index.ts +3 -0
  35. package/src/plugins/plugins/nextjs.ts +19 -0
  36. package/src/plugins/plugins/vite.ts +14 -0
  37. package/src/plugins/plugins/vitest.ts +14 -0
  38. package/src/runtime/async-runner.ts +35 -7
  39. package/src/runtime/background-runner.ts +529 -144
  40. package/src/runtime/chain-parser.ts +5 -1
  41. package/src/runtime/chain-runner.ts +9 -3
  42. package/src/runtime/checkpoint.ts +262 -180
  43. package/src/runtime/child-pi.ts +164 -37
  44. package/src/runtime/crash-recovery.ts +25 -14
  45. package/src/runtime/crew-agent-records.ts +2 -0
  46. package/src/runtime/diagnostic-export.ts +1 -1
  47. package/src/runtime/dynamic-script-runner.ts +10 -7
  48. package/src/runtime/foreground-watchdog.ts +1 -1
  49. package/src/runtime/heartbeat-watcher.ts +19 -2
  50. package/src/runtime/intercom-bridge.ts +7 -0
  51. package/src/runtime/iteration-hooks.ts +1 -1
  52. package/src/runtime/manifest-cache.ts +4 -0
  53. package/src/runtime/orphan-worker-registry.ts +444 -0
  54. package/src/runtime/parent-guard.ts +70 -16
  55. package/src/runtime/pi-args.ts +437 -14
  56. package/src/runtime/pi-spawn.ts +1 -0
  57. package/src/runtime/post-checks.ts +1 -1
  58. package/src/runtime/retry-runner.ts +9 -3
  59. package/src/runtime/run-tracker.ts +38 -10
  60. package/src/runtime/sandbox.ts +17 -2
  61. package/src/runtime/scheduler.ts +25 -2
  62. package/src/runtime/skill-effectiveness.ts +105 -62
  63. package/src/runtime/skill-instructions.ts +110 -32
  64. package/src/runtime/stale-reconciler.ts +310 -69
  65. package/src/runtime/subagent-manager.ts +251 -57
  66. package/src/runtime/task-health.ts +76 -0
  67. package/src/runtime/task-id.ts +20 -0
  68. package/src/runtime/task-runner/live-executor.ts +1 -1
  69. package/src/runtime/task-runner/progress.ts +1 -1
  70. package/src/runtime/task-runner/state-helpers.ts +110 -6
  71. package/src/runtime/task-runner.ts +92 -70
  72. package/src/runtime/team-runner.ts +186 -20
  73. package/src/schema/team-tool-schema.ts +27 -9
  74. package/src/skills/discover-skills.ts +9 -3
  75. package/src/state/active-run-registry.ts +170 -38
  76. package/src/state/artifact-store.ts +25 -13
  77. package/src/state/atomic-write-v2.ts +86 -0
  78. package/src/state/atomic-write.ts +346 -55
  79. package/src/state/blob-store.ts +178 -10
  80. package/src/state/crew-init.ts +161 -28
  81. package/src/state/decision-ledger.ts +172 -111
  82. package/src/state/event-log-rotation.ts +82 -52
  83. package/src/state/event-log.ts +254 -70
  84. package/src/state/health-store.ts +71 -0
  85. package/src/state/locks.ts +102 -20
  86. package/src/state/mailbox.ts +45 -7
  87. package/src/state/observation-store.ts +4 -1
  88. package/src/state/run-graph.ts +141 -130
  89. package/src/state/run-metrics.ts +24 -8
  90. package/src/state/session-state-map.ts +51 -0
  91. package/src/state/state-store.ts +330 -43
  92. package/src/ui/live-run-sidebar.ts +1 -1
  93. package/src/ui/loaders.ts +4 -0
  94. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  95. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  96. package/src/ui/powerbar-publisher.ts +1 -1
  97. package/src/ui/run-action-dispatcher.ts +2 -2
  98. package/src/ui/run-snapshot-cache.ts +1 -1
  99. package/src/ui/status-colors.ts +5 -1
  100. package/src/ui/theme-adapter.ts +80 -1
  101. package/src/ui/tool-progress-formatter.ts +9 -5
  102. package/src/ui/tool-render.ts +4 -0
  103. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  104. package/src/ui/tool-renderers/index.ts +627 -0
  105. package/src/ui/widget/index.ts +224 -0
  106. package/src/ui/widget/widget-formatters.ts +148 -0
  107. package/src/ui/widget/widget-model.ts +90 -0
  108. package/src/ui/widget/widget-renderer.ts +130 -0
  109. package/src/ui/widget/widget-types.ts +37 -0
  110. package/src/utils/env-filter.ts +66 -0
  111. package/src/utils/file-coalescer.ts +9 -1
  112. package/src/utils/guards.ts +110 -0
  113. package/src/utils/paths.ts +80 -5
  114. package/src/utils/redaction.ts +6 -1
  115. package/src/utils/safe-paths.ts +281 -10
  116. package/src/worktree/cleanup.ts +112 -24
  117. package/src/worktree/worktree-manager.ts +128 -15
  118. package/test-bugs-all.mjs +10 -6
  119. package/test-integration-check.ts +4 -0
@@ -1,32 +1,142 @@
1
+ import * as crypto from "node:crypto";
1
2
  import * as fs from "node:fs";
3
+ import * as os from "node:os";
2
4
  import * as path from "node:path";
3
5
  import { logInternalError } from "../utils/internal-error.ts";
4
6
  import { sleepSync } from "../utils/sleep.ts";
5
7
 
8
+ function hashContent(content: string): string {
9
+ return crypto.createHash("sha256").update(content, "utf-8").digest("hex");
10
+ }
11
+
6
12
  const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
13
+ const RETRYABLE_LINK_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOENT"]);
7
14
 
8
15
  /**
9
16
  * Symlink-safe file write guard (caveman-inspired).
10
17
  * Returns true if the path is safe to write, false if it's a symlink or
11
18
  * inside a symlinked directory owned by another user.
19
+ *
20
+ * This walks the full ancestor chain to detect any symlinks in the path,
21
+ * preventing attacks where an intermediate ancestor is a symlink.
22
+ *
23
+ * Platform note: The ownership verification (via process.getuid) is only
24
+ * available on Unix-like platforms (Linux, macOS). On Windows or other
25
+ * platforms where getuid is unavailable, the ownership check is skipped
26
+ * and only symlink detection is performed. This means symlink safety
27
+ * verification is weaker on non-Unix platforms. Consider using
28
+ * platform-specific ownership verification (e.g., icacls on Windows)
29
+ * for stronger guarantees on those platforms.
30
+ *
31
+ * Design note: Ownership verification (getuid/getgid) is only performed
32
+ * when a path component is itself a symlink. For paths where all
33
+ * components are regular directories, no ownership verification occurs.
34
+ * This is intentional: an attacker who can create directories within
35
+ * baseDir could exploit this, but this is mitigated by the boundary
36
+ * check (realDir.startsWith(baseDir + path.sep)). In the pi-crew
37
+ * context, baseDir is always within the user's own pi-crew state
38
+ * directory tree (userPiRoot), so this is a low-risk design choice.
39
+ * Callers must ensure baseDir is always inside a protected user
40
+ * directory and never in a shared or world-writable location.
12
41
  */
13
- function isSymlinkSafePath(filePath: string): boolean {
42
+ export function isSymlinkSafePath(filePath: string): boolean {
14
43
  try {
15
- const dir = path.dirname(filePath);
16
- // Check if parent directory is a symlink
17
- try {
18
- const dirStat = fs.lstatSync(dir);
19
- if (dirStat.isSymbolicLink()) {
20
- // Resolve and verify ownership on Unix
21
- const realDir = fs.realpathSync(dir);
22
- const realStat = fs.statSync(realDir);
23
- if (!realStat.isDirectory()) return false;
24
- if (typeof process.getuid === "function" && realStat.uid !== process.getuid()) return false;
44
+ // Note: baseDir is intentionally NOT resolved here with realpathSync.
45
+ // The while loop below walks the full ancestor chain and the explicit
46
+ // check at lines 94-101 verifies baseDir itself is not a symlink.
47
+ // This redundant early resolution was removed per Issue 1.
48
+ const baseDir = path.dirname(filePath);
49
+ // Walk the full ancestor chain to detect any symlinks
50
+ let currentPath = filePath;
51
+ while (currentPath !== path.dirname(currentPath)) {
52
+ const dir = path.dirname(currentPath);
53
+ try {
54
+ const dirStat = fs.lstatSync(dir);
55
+ if (dirStat.isSymbolicLink()) {
56
+ // Resolve and verify ownership on Unix
57
+ const realDir = fs.realpathSync(dir);
58
+ // Issue 1 fix: use resolved baseDir for boundary verification.
59
+ // Accept if realDir is inside baseDir, equals baseDir, or is an
60
+ // ancestor of baseDir. On macOS, /var/folders is a symlink to
61
+ // /private/var/folders — resolve baseDir too for comparison.
62
+ // Walk up ancestors to find the deepest existing one and resolve it.
63
+ let realBase = baseDir;
64
+ for (let walk = baseDir; walk !== path.dirname(walk); walk = path.dirname(walk)) {
65
+ try {
66
+ const resolved = fs.realpathSync(walk);
67
+ // Found existing ancestor — join with remaining non-existent parts
68
+ realBase = path.join(resolved, path.relative(walk, baseDir));
69
+ break;
70
+ } catch { /* not found yet, keep walking */ }
71
+ }
72
+ const realDirNorm = realDir.endsWith(path.sep) ? realDir : realDir + path.sep;
73
+ const realBaseNorm = realBase.endsWith(path.sep) ? realBase : realBase + path.sep;
74
+ const isAncestor = realBaseNorm.startsWith(realDirNorm) || realBase === realDir;
75
+ if (!isAncestor && !realDirNorm.startsWith(realBaseNorm)) return false;
76
+ const realStat = fs.statSync(realDir);
77
+ if (!realStat.isDirectory()) return false;
78
+ // Only check ownership for user-controlled directories.
79
+ // System directories like /private/var/folders on macOS are owned
80
+ // by root but are safe to use (the OS manages them). Skip uid check
81
+ // for directories not owned by the current user only when the real
82
+ // path is inside or is an ancestor of a known system temp directory.
83
+ if (typeof process.getuid === "function" && realStat.uid !== process.getuid()) {
84
+ const systemTmp = (typeof os.tmpdir === "function" ? os.tmpdir() : "/tmp").toLowerCase();
85
+ let realSystemTmp = systemTmp;
86
+ try { realSystemTmp = fs.realpathSync(systemTmp).toLowerCase(); } catch { /* ok */ }
87
+ const realDirLower = realDir.toLowerCase();
88
+ const dirNorm = realDirLower.endsWith(path.sep) ? realDirLower : realDirLower + path.sep;
89
+ const tmpNorm = realSystemTmp.endsWith(path.sep) ? realSystemTmp : realSystemTmp + path.sep;
90
+ // Accept if realDir is inside tmpdir OR is an ancestor of tmpdir
91
+ // (e.g. /private/var/folders is an ancestor of /private/var/folders/.../T/)
92
+ const isInsideTmp = dirNorm.startsWith(tmpNorm) || realDirLower.startsWith(systemTmp);
93
+ const isAncestorOfTmp = tmpNorm.startsWith(dirNorm) || realSystemTmp.startsWith(realDirLower);
94
+ // Also accept canonical system temp paths:
95
+ // - /tmp → /private/tmp (macOS)
96
+ // - /var/folders → /private/var/folders (macOS)
97
+ const isSystemTmp = process.platform === "darwin" && (
98
+ realDirLower === "/tmp" || realDirLower === "/private/tmp" ||
99
+ realDirLower === "/var/folders" || realDirLower === "/private/var/folders" ||
100
+ realDirLower.startsWith("/var/folders/") || realDirLower.startsWith("/private/var/folders/")
101
+ );
102
+ if (!isInsideTmp && !isAncestorOfTmp && !isSystemTmp) {
103
+ return false;
104
+ }
105
+ }
106
+ }
107
+ } catch (err) {
108
+ // Directory doesn't exist yet — that's OK, mkdirSync will create it.
109
+ // For permission errors (EACCES, EPERM), we cannot verify the path
110
+ // is safe, so treat it as unsafe rather than returning true.
111
+ const code = (err as NodeJS.ErrnoException).code;
112
+ if (code === "ENOENT") {
113
+ // OK - directory doesn't exist yet
114
+ } else if (code === "EACCES" || code === "EPERM") {
115
+ // Permission error - cannot verify path is safe
116
+ logInternalError("isSymlinkSafePath.lstat.permission", err, `dir=${dir}`);
117
+ return false;
118
+ } else {
119
+ // Other errors - log but continue (better to be conservative)
120
+ logInternalError("isSymlinkSafePath.lstat", err, `dir=${dir}`);
121
+ }
25
122
  }
26
- } catch {
27
- // Directory doesn't exist yet — that's OK, mkdirSync will create it
123
+ currentPath = dir;
28
124
  }
29
125
 
126
+
127
+ // Issue 1 fix: verify baseDir itself is not a symlink.
128
+ // The while loop above walks ancestors but stops when currentPath reaches
129
+ // root (currentPath === path.dirname(currentPath)). If baseDir is a symlink
130
+ // and is also the root of the path, it would not be checked. This explicit
131
+ // check ensures baseDir is verified regardless of where the loop terminates.
132
+ if (baseDir !== path.dirname(filePath)) {
133
+ try {
134
+ const baseDirStat = fs.lstatSync(baseDir);
135
+ if (baseDirStat.isSymbolicLink()) return false;
136
+ } catch {
137
+ // baseDir does not exist yet — that is OK
138
+ }
139
+ }
30
140
  // Check if target file itself is a symlink
31
141
  try {
32
142
  const fileStat = fs.lstatSync(filePath);
@@ -51,6 +161,83 @@ function isRetryableRenameError(error: unknown): boolean {
51
161
  return Boolean(error && typeof error === "object" && "code" in error && RETRYABLE_RENAME_CODES.has(String((error as NodeJS.ErrnoException).code)));
52
162
  }
53
163
 
164
+ function isRetryableLinkError(error: unknown): boolean {
165
+ return Boolean(error && typeof error === "object" && "code" in error && RETRYABLE_LINK_CODES.has(String((error as NodeJS.ErrnoException).code)));
166
+ }
167
+
168
+ /**
169
+ * Issue 1 fix: rename via link+unlink instead of rename.
170
+ * Unlike rename, link() does NOT follow symlinks at the destination path.
171
+ * It atomically creates a hard link to the source. We then unlink the source.
172
+ * This prevents TOCTOU attacks where an attacker plants a symlink at the
173
+ * destination between the check and the rename operation.
174
+ */
175
+ function renameWithLinkSync(tempPath: string, filePath: string, retries = 8): void {
176
+ let lastError: unknown;
177
+ for (let attempt = 0; attempt <= retries; attempt++) {
178
+ try {
179
+ // Windows: hard links fail with ENOENT when short-name and long-name
180
+ // path aliases are mixed (e.g. RUNNER~1 vs runneradmin). Use
181
+ // renameSync which handles this correctly via MoveFileEx.
182
+ if (process.platform === "win32") {
183
+ try { fs.unlinkSync(filePath); } catch { /* destination may not exist */ }
184
+ try {
185
+ fs.renameSync(tempPath, filePath);
186
+ return;
187
+ } catch (renameError) {
188
+ lastError = renameError;
189
+ if (!isRetryableLinkError(renameError) || attempt === retries) break;
190
+ }
191
+ } else {
192
+ // First try to unlink any existing file at destination (hard links don't follow symlinks)
193
+ try { fs.unlinkSync(filePath); } catch { /* destination may not exist */ }
194
+ // Create hard link - does NOT follow symlinks at filePath
195
+ fs.linkSync(tempPath, filePath);
196
+ // Successfully linked - now unlink the temp file
197
+ fs.unlinkSync(tempPath);
198
+ return;
199
+ }
200
+ } catch (error) {
201
+ lastError = error;
202
+ if (!isRetryableLinkError(error) || attempt === retries) break;
203
+ }
204
+ const base = Math.min(500, 10 * 2 ** attempt);
205
+ const jitter = base * 0.2 * (Math.random() * 2 - 1);
206
+ sleepSync(Math.max(1, Math.round(base + jitter)));
207
+ }
208
+ throw lastError;
209
+ }
210
+
211
+ async function renameWithLinkAsync(tempPath: string, filePath: string, retries = 8): Promise<void> {
212
+ let lastError: unknown;
213
+ for (let attempt = 0; attempt <= retries; attempt++) {
214
+ try {
215
+ if (process.platform === "win32") {
216
+ try { await fs.promises.unlink(filePath); } catch { /* destination may not exist */ }
217
+ try {
218
+ await fs.promises.rename(tempPath, filePath);
219
+ return;
220
+ } catch (renameError) {
221
+ lastError = renameError;
222
+ if (!isRetryableLinkError(renameError) || attempt === retries) break;
223
+ }
224
+ } else {
225
+ try { await fs.promises.unlink(filePath); } catch { /* destination may not exist */ }
226
+ await fs.promises.link(tempPath, filePath);
227
+ await fs.promises.unlink(tempPath);
228
+ return;
229
+ }
230
+ } catch (error) {
231
+ lastError = error;
232
+ if (!isRetryableLinkError(error) || attempt === retries) break;
233
+ }
234
+ const base = Math.min(500, 10 * 2 ** attempt);
235
+ const jitter = base * 0.2 * (Math.random() * 2 - 1);
236
+ await sleep(Math.max(1, Math.round(base + jitter)));
237
+ }
238
+ throw lastError;
239
+ }
240
+
54
241
  export function renameWithRetry(tempPath: string, filePath: string, retries = 8, rename: (oldPath: string, newPath: string) => void = fs.renameSync): void {
55
242
  let lastError: unknown;
56
243
  for (let attempt = 0; attempt <= retries; attempt++) {
@@ -95,14 +282,43 @@ export async function renameWithRetryAsync(tempPath: string, filePath: string, r
95
282
  /** Test alias for renameWithRetryAsync. */
96
283
  export const __test__renameWithRetryAsync = renameWithRetryAsync;
97
284
 
98
- export function atomicWriteFile(filePath: string, content: string): void {
285
+ export function atomicWriteFile(filePath: string, content: string, expectedHash?: string): void {
99
286
  if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
100
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
101
- const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
287
+ // On Windows, resolve parent dir through realpathSync to handle short-name
288
+ // vs long-name path alias (e.g. RUNNER~1 vs runneradmin). Without this,
289
+ // mkdirSync may succeed but openSync fails with ENOENT because the OS
290
+ // sees the paths as different locations.
291
+ let dirPath = path.dirname(filePath);
292
+ if (process.platform === "win32") {
293
+ try {
294
+ const realDir = fs.realpathSync(dirPath);
295
+ if (realDir !== dirPath) dirPath = realDir;
296
+ } catch {
297
+ // dirPath may not exist yet — mkdirSync will create it
298
+ }
299
+ filePath = path.join(dirPath, path.basename(filePath));
300
+ }
301
+ try {
302
+ fs.mkdirSync(dirPath, { recursive: true });
303
+ } catch (error) {
304
+ if (process.platform === "win32" && (error as NodeJS.ErrnoException).code === "EPERM") {
305
+ try {
306
+ const realDir = fs.realpathSync(dirPath);
307
+ if (realDir !== dirPath) {
308
+ fs.mkdirSync(realDir, { recursive: true });
309
+ dirPath = realDir;
310
+ }
311
+ } catch { /* ignore – will fail at write time with better error */ }
312
+ } else {
313
+ throw error;
314
+ }
315
+ }
316
+ const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
102
317
  // Write temp with restrictive permissions
103
318
  const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
319
+ let fd: number | undefined;
104
320
  try {
105
- const fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
321
+ fd = fs.openSync(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
106
322
  // Post-open verification: on Windows O_NOFOLLOW is 0, so verify FD is a regular file
107
323
  const openedStat = fs.fstatSync(fd);
108
324
  if (!openedStat.isFile()) {
@@ -112,15 +328,26 @@ export function atomicWriteFile(filePath: string, content: string): void {
112
328
  fs.writeSync(fd, content, undefined, "utf-8");
113
329
  fs.closeSync(fd);
114
330
  try {
115
- renameWithRetry(tempPath, filePath);
331
+ // Issue 1 fix: re-check symlink safety immediately before rename.
332
+ // Between the initial isSymlinkSafePath check (line 147) and here,
333
+ // an attacker with control of an ancestor directory could plant a
334
+ // symlink at the target path. If rename succeeds with a symlink at
335
+ // target, the symlink is atomically replaced with attacker's content.
336
+ // The post-rename lstat check only runs on rename failure, so we must
337
+ // check BEFORE the rename to catch this TOCTOU race.
338
+ if (!isSymlinkSafePath(filePath)) {
339
+ throw new Error(`Refusing to rename: target became a symlink or inside untrusted directory: ${filePath}`);
340
+ }
341
+ // Issue 1 fix: use link+unlink instead of rename to avoid following symlinks
342
+ renameWithLinkSync(tempPath, filePath);
116
343
  } catch (renameError) {
117
- // H3 fix: re-check symlink safety before fallback.
118
- // Between isSymlinkSafePath at top and rename attempt, the file
119
- // could have been replaced with a symlink (TOCTOU). Refuse if so.
344
+ // Issue 4 fix: use finally block to guarantee temp file cleanup.
345
+ // Between the initial isSymlinkSafePath check and rename attempt,
346
+ // the file could have been replaced with a symlink (TOCTOU).
347
+ // If lstat check below throws unexpectedly, finally ensures cleanup.
120
348
  try {
121
349
  const lstat = fs.lstatSync(filePath);
122
350
  if (lstat.isSymbolicLink()) {
123
- try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
124
351
  throw renameError;
125
352
  }
126
353
  } catch (checkError) {
@@ -131,33 +358,18 @@ export function atomicWriteFile(filePath: string, content: string): void {
131
358
  throw checkError;
132
359
  }
133
360
  }
134
- // Fallback: if rename fails (Windows EPERM/EBUSY), try direct write.
135
- // This is less atomic but avoids data loss when concurrent writers contend.
136
- try {
137
- fs.writeFileSync(filePath, content, "utf-8");
138
- } catch {
139
- throw renameError;
140
- }
141
- try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
142
- }
143
- } catch (error) {
144
- let matches = false;
145
- try {
146
- const existing = fs.readFileSync(filePath, "utf-8");
147
- matches = existing === content;
148
- } catch {
149
- /* ignore */
361
+ // Issue 2 fix: do NOT fall back to non-atomic writeFileSync.
362
+ // The rename failed after retries throw the error rather than
363
+ // risking data corruption via a non-atomic write. Callers should
364
+ // handle this error or use a different strategy for contended files.
365
+ throw renameError;
150
366
  }
151
- if (matches) {
367
+ } finally {
368
+ // Issue 4 fix: always clean up temp file, regardless of success or error path.
369
+ // This ensures no orphaned temp files remain when errors occur at any point.
370
+ if (fd !== undefined) {
152
371
  try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
153
- return;
154
372
  }
155
- try {
156
- fs.rmSync(tempPath, { force: true });
157
- } catch (cleanupError) {
158
- logInternalError("atomic-write.cleanup", cleanupError, `tempPath=${tempPath}`);
159
- }
160
- throw error;
161
373
  }
162
374
  }
163
375
 
@@ -165,7 +377,7 @@ export function atomicWriteFile(filePath: string, content: string): void {
165
377
  export async function atomicWriteFileAsync(filePath: string, content: string): Promise<void> {
166
378
  if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
167
379
  await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
168
- const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
380
+ const tempPath = `${filePath}.${crypto.randomUUID()}.tmp`;
169
381
  try {
170
382
  const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0;
171
383
  const fd = await fs.promises.open(tempPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW, 0o600);
@@ -178,7 +390,19 @@ export async function atomicWriteFileAsync(filePath: string, content: string): P
178
390
  await fd.writeFile(content, "utf-8");
179
391
  await fd.close();
180
392
  try {
181
- await renameWithRetryAsync(tempPath, filePath);
393
+ // Re-check symlink safety immediately before rename.
394
+ // Between the initial isSymlinkSafePath check and here,
395
+ // an attacker with control of an ancestor directory could plant a
396
+ // symlink at the target path. If rename succeeds with a symlink at
397
+ // target, the symlink is atomically replaced with attacker's content.
398
+ // The post-rename lstat check only runs on rename failure, so we must
399
+ // check BEFORE the rename to catch this TOCTOU race.
400
+ if (!isSymlinkSafePath(filePath)) {
401
+ try { await fs.promises.rm(tempPath, { force: true }); } catch { /* best-effort */ }
402
+ throw new Error(`Refusing to rename: target became a symlink or inside untrusted directory: ${filePath}`);
403
+ }
404
+ // Issue 1 fix: use link+unlink instead of rename to avoid following symlinks
405
+ await renameWithLinkAsync(tempPath, filePath);
182
406
  } catch (renameError) {
183
407
  let matches = false;
184
408
  try {
@@ -232,48 +456,115 @@ export async function atomicWriteJsonAsync<T>(filePath: string, value: T): Promi
232
456
  interface CoalescedAtomicWrite {
233
457
  content: string;
234
458
  timer: ReturnType<typeof setTimeout>;
459
+ coalesceMs: number;
460
+ retryCount: number;
461
+ /** Generation counter to detect stale flushes (Issue 2 fix) */
462
+ generation: number;
235
463
  }
464
+ const MAX_FLUSH_RETRIES = 5;
236
465
  const pendingAtomicWrites = new Map<string, CoalescedAtomicWrite>();
237
466
  const DEFAULT_ATOMIC_COALESCE_MS = 50;
467
+ /** Issue 2 fix: generation counter for coalesced writes */
468
+ let writeGeneration = 0;
469
+
470
+ // Issue 1 fix: guard against concurrent AND re-entrant flushes using depth counter
471
+ let flushInProgress = 0;
238
472
 
473
+ /**
474
+ * Buffer a JSON write and flush it after `coalesceMs` ms (default 50).
475
+ * Multiple writes to the same path within the window collapse to one disk write.
476
+ *
477
+ * @see The coalescing caveat: a `readJsonFile` call between buffer and flush
478
+ * sees the previous on-disk content. Callers needing read-after-write
479
+ * must call `flushPendingAtomicWrites()` first or use `atomicWriteJson`.
480
+ */
239
481
  export function atomicWriteJsonCoalesced<T>(filePath: string, value: T, coalesceMs = DEFAULT_ATOMIC_COALESCE_MS): void {
240
482
  const content = `${JSON.stringify(value, null, 2)}\n`;
241
483
  const previous = pendingAtomicWrites.get(filePath);
242
484
  if (previous) clearTimeout(previous.timer);
243
485
  const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), coalesceMs);
244
486
  timer.unref();
245
- pendingAtomicWrites.set(filePath, { content, timer });
487
+ // Issue 2 fix: increment generation for each new entry
488
+ const generation = ++writeGeneration;
489
+ pendingAtomicWrites.set(filePath, { content, timer, coalesceMs, retryCount: 0, generation });
246
490
  }
247
491
 
248
492
  function flushOnePendingAtomicWrite(filePath: string): void {
249
493
  const entry = pendingAtomicWrites.get(filePath);
250
494
  if (!entry) return;
251
- pendingAtomicWrites.delete(filePath);
495
+ // Issue 2 fix: capture generation before flush to detect stale flushes.
496
+ // If a new write arrives during the flush, the generation will change
497
+ // and we will NOT delete the newer entry after the flush completes.
498
+ const savedGeneration = entry.generation;
252
499
  clearTimeout(entry.timer);
253
500
  try {
254
501
  atomicWriteFile(filePath, entry.content);
502
+ // Issue 2 fix: Verify generation hasn't changed before deleting.
503
+ // A concurrent write may have replaced entry with a newer one during the flush.
504
+ // Only delete if generation matches (not a newer entry).
505
+ // This prevents orphaning newer entries that arrived during the flush.
506
+ if (pendingAtomicWrites.get(filePath)?.generation === savedGeneration) {
507
+ pendingAtomicWrites.delete(filePath);
508
+ }
255
509
  } catch (error) {
256
510
  logInternalError("atomic-write.coalesced-flush", error, filePath);
511
+ // Issue 1 fix: set a fresh timer for failed entries before returning.
512
+ // This ensures failed entries are retried without waiting for another
513
+ // write to arrive. Only set timer if this entry is still current
514
+ // (not replaced by a newer write during the flush).
515
+ const current = pendingAtomicWrites.get(filePath);
516
+ if (current?.generation === savedGeneration) {
517
+ current.retryCount++;
518
+ if (current.retryCount >= MAX_FLUSH_RETRIES) {
519
+ // Max retries exceeded - remove entry and propagate error to callers
520
+ pendingAtomicWrites.delete(filePath);
521
+ // Re-throw so callers can handle the persistent failure
522
+ throw error;
523
+ }
524
+ // Exponential backoff: base delay * 2^(retryCount-1), capped at 30 seconds
525
+ const backoffMs = Math.min(30000, current.coalesceMs * Math.pow(2, current.retryCount - 1));
526
+ const timer = setTimeout(() => flushOnePendingAtomicWrite(filePath), backoffMs);
527
+ timer.unref();
528
+ current.timer = timer;
529
+ }
257
530
  }
258
531
  }
259
532
 
260
533
  /** Flush every queued coalesced write synchronously. Safe to call any time. */
261
534
  export function flushPendingAtomicWrites(): void {
262
- for (const filePath of [...pendingAtomicWrites.keys()]) flushOnePendingAtomicWrite(filePath);
535
+ if (flushInProgress > 0) return;
536
+ flushInProgress++;
537
+ try {
538
+ for (const filePath of [...pendingAtomicWrites.keys()]) flushOnePendingAtomicWrite(filePath);
539
+ } finally {
540
+ flushInProgress--;
541
+ }
263
542
  }
264
543
 
544
+ // Defense-in-depth: signal handlers must return immediately.
545
+ // Use setImmediate so the handler exits before any sync I/O runs.
546
+ // This prevents the main thread from being blocked if a signal
547
+ // arrives while the user is idle in the terminal.
265
548
  process.on("exit", () => flushPendingAtomicWrites());
266
- process.on("SIGTERM", () => flushPendingAtomicWrites());
267
- process.on("SIGINT", () => flushPendingAtomicWrites());
549
+ process.on("SIGTERM", () => setImmediate(() => flushPendingAtomicWrites()));
550
+ process.on("SIGINT", () => setImmediate(() => flushPendingAtomicWrites()));
268
551
 
269
552
  export function readJsonFile<T>(filePath: string): T | undefined {
270
553
  try {
271
554
  return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
272
555
  } catch (err) {
273
556
  const code = (err as NodeJS.ErrnoException).code;
274
- if (code !== "ENOENT" && code !== "ENOTDIR") {
557
+ if (code === "ENOENT" || code === "ENOTDIR") {
558
+ // Expected: file doesn't exist or path is not a directory
559
+ return undefined;
560
+ } else if (code === "EACCES" || code === "EPERM") {
561
+ // Permission error - log as warning but still return undefined
562
+ logInternalError("readJsonFile.permission", err, `filePath=${filePath}`);
563
+ return undefined;
564
+ } else {
565
+ // Other unexpected errors - log as internal error
275
566
  logInternalError("readJsonFile", err, `filePath=${filePath}`);
567
+ return undefined;
276
568
  }
277
- return undefined;
278
569
  }
279
570
  }