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
@@ -81,7 +81,7 @@ export function dispatchHealthRecovery(ctx: ExtensionContext, runId: string): Pr
81
81
 
82
82
  export async function dispatchKillStaleWorkers(ctx: ExtensionContext, runId: string): Promise<RunActionResult> {
83
83
  try {
84
- const loaded = loadRunManifestById(ctx.cwd, runId);
84
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
85
85
  if (!loaded) return { ok: false, message: `Run '${runId}' not found.` };
86
86
  const currentMs = Date.now();
87
87
  const staleMs = 60_000;
@@ -113,7 +113,7 @@ export async function dispatchDiagnosticExport(ctx: ExtensionContext, runId: str
113
113
 
114
114
  /** @internal */
115
115
  function defaultNudgeAgentId(ctx: Pick<ExtensionContext, "cwd">, runId: string): string | undefined {
116
- const loaded = loadRunManifestById(ctx.cwd, runId);
116
+ const loaded = loadRunManifestById(ctx.cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
117
117
  if (!loaded) return undefined;
118
118
  return readCrewAgents(loaded.manifest).find((agent) => agent.status === "running" || agent.status === "queued")?.taskId;
119
119
  }
@@ -652,7 +652,7 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
652
652
  function build(runId: string, previous?: CacheEntry): CacheEntry {
653
653
  let loaded: ReturnType<typeof loadRunManifestById>;
654
654
  try {
655
- loaded = loadRunManifestById(cwd, runId);
655
+ loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
656
656
  } catch {
657
657
  if (previous) return previous;
658
658
  throw new Error(`Run '${runId}' could not be parsed.`);
@@ -1,10 +1,56 @@
1
1
  import { isSecretKey } from "./redaction.ts";
2
2
 
3
+ // Well-known LLM provider API keys that are intentionally allowlisted in
4
+ // child-pi.ts and async-runner.ts for pass-through to worker processes.
5
+ // These are "secret" by pattern (contain KEY/API) but are safe to allowlist
6
+ // because they are standard provider credentials, not arbitrary secrets.
7
+ const KNOWN_PROVIDER_KEYS = new Set([
8
+ "MINIMAX_API_KEY", "MINIMAX_GROUP_ID",
9
+ "OPENAI_API_KEY", "OPENAI_ORG_ID",
10
+ "ANTHROPIC_API_KEY",
11
+ "GOOGLE_API_KEY", "GOOGLE_GENERATIVE_LANGUAGE_API_KEY",
12
+ "AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT",
13
+ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION",
14
+ "ZEU_API_KEY", "ZERODEV_API_KEY",
15
+ ]);
16
+
17
+ function isKnownProviderKey(key: string): boolean {
18
+ return KNOWN_PROVIDER_KEYS.has(key);
19
+ }
20
+
3
21
  export interface SanitizeEnvOptions {
4
22
  /** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
5
23
  allowList?: string[];
6
24
  }
7
25
 
26
+ // Keywords that indicate a secret env var
27
+ const SECRET_SUFFIXES = ["token", "api", "key", "password", "passwd", "secret", "credential", "authorization", "private"];
28
+
29
+ /**
30
+ * Check if a glob pattern could match secret env vars.
31
+ * A pattern like "PI_*" is dangerous because it could match PI_TOKEN, PI_API_KEY, etc.
32
+ *
33
+ * Exception: `PI_CREW_*` is a controlled namespace — the pi-crew codebase owns
34
+ * every PI_CREW_* env var (PI_CREW_PARENT_PID, PI_CREW_ADAPTIVE_REPAIR, etc.)
35
+ * and none of them are secrets. Allowing the glob here lets child Pi processes
36
+ * inherit our config without needing a per-var allowlist.
37
+ */
38
+ function isDangerousGlob(pattern: string): boolean {
39
+ if (!pattern.endsWith("*")) return false;
40
+ const prefix = pattern.slice(0, -1); // Remove trailing *
41
+ if (prefix === "") return true; // Single "*" matches everything
42
+ // PI_CREW_* is the pi-crew controlled namespace — no secrets live here.
43
+ // This covers PI_CREW_*, PI_CREW_TEAMS_*, PI_CREW_AGENT_*, etc.
44
+ if (prefix.startsWith("PI_CREW_") || prefix === "PI_CREW") return false;
45
+ // Check if combining the prefix with any secret suffix would create a secret key
46
+ for (const suffix of SECRET_SUFFIXES) {
47
+ if (isSecretKey(prefix + suffix)) {
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+
8
54
  /**
9
55
  * Strip env vars whose keys look like secrets before passing to child processes.
10
56
  *
@@ -14,11 +60,31 @@ export interface SanitizeEnvOptions {
14
60
  export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnvOptions): Record<string, string> {
15
61
  const filtered: Record<string, string> = {};
16
62
  if (options?.allowList && options.allowList.length > 0) {
63
+ // Validate allowlist patterns don't match secrets
64
+ for (const pattern of options.allowList) {
65
+ if (isDangerousGlob(pattern)) {
66
+ throw new Error(`Allowlist pattern "${pattern}" could match secret env vars. Use a more specific pattern.`);
67
+ }
68
+ // Validate non-glob entries don't look like secret keys.
69
+ // Exception 1: if the key exists in env, it was intentionally set and
70
+ // should be allowed through (user knows what they're doing).
71
+ // Exception 2: known provider keys (MINIMAX_API_KEY, etc.) are always
72
+ // allowed because they are standard provider credentials explicitly
73
+ // listed in async-runner.ts / child-pi.ts allowlists.
74
+ if (!pattern.endsWith("*") && isSecretKey(pattern) && !(pattern in env) && !isKnownProviderKey(pattern)) {
75
+ throw new Error(`Allowlist entry "${pattern}" looks like a secret key. Use a more specific pattern.`);
76
+ }
77
+ }
17
78
  const matchers = options.allowList.map((p) => {
18
79
  if (p.endsWith("*")) {
80
+ // Glob pattern: matches keys that start with the prefix AND have
81
+ // at least one additional character (distinguishes "PI_CREW_*" from "PI_CREW_").
82
+ // For example, "PI_CREW_*" matches "PI_CREW_DEPTH" but not "PI_CREW_".
83
+ // This ensures trailing glob patterns require extra chars, not exact-prefix-only matches.
19
84
  const prefix = p.slice(0, -1);
20
85
  return (k: string) => k.startsWith(prefix) && k.length > prefix.length;
21
86
  }
87
+ // Exact match is case-sensitive; Unix env vars are uppercase by convention.
22
88
  return (k: string) => k === p;
23
89
  });
24
90
  for (const [key, value] of Object.entries(env)) {
@@ -6,7 +6,15 @@ interface TimerApi {
6
6
  }
7
7
 
8
8
  const defaultTimerApi: TimerApi = {
9
- setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
9
+ setTimeout: (handler, delayMs) => {
10
+ const t = setTimeout(handler, delayMs);
11
+ // Defense in depth: never let a coalescer timer block process exit.
12
+ // The timer may be cleared before it fires; .unref() is idempotent.
13
+ if (typeof t === "object" && t && "unref" in t && typeof t.unref === "function") {
14
+ t.unref();
15
+ }
16
+ return t;
17
+ },
10
18
  clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
11
19
  };
12
20
 
@@ -9,7 +9,49 @@ export function packageRoot(): string {
9
9
 
10
10
  export function userPiRoot(): string {
11
11
  const home = process.env.PI_TEAMS_HOME?.trim() || os.homedir();
12
- return path.join(home, ".pi", "agent");
12
+ const resolved = path.join(home, ".pi", "agent");
13
+
14
+ // Reject symlinks to prevent confusion attacks where PI_TEAMS_HOME points to
15
+ // an attacker-controlled target via a user-owned symlink.
16
+ // We use lstatSync (does NOT follow) to detect symlinks before they are resolved.
17
+ // ENOENT is acceptable — the directory may not exist yet (caller will create it).
18
+ let isSymlink = false;
19
+ try {
20
+ const lstats = fs.lstatSync(resolved);
21
+ isSymlink = lstats.isSymbolicLink();
22
+ } catch (err: unknown) {
23
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") throw err;
24
+ // Path doesn't exist yet — caller will create it. Skip further validation.
25
+ return resolved;
26
+ }
27
+ if (isSymlink) {
28
+ throw new Error(
29
+ `userPiRoot: PI_TEAMS_HOME path "${resolved}" is a symlink. ` +
30
+ "Symlinks are not supported for PI_TEAMS_HOME to prevent confusion attacks. " +
31
+ "Set PI_TEAMS_HOME to a direct path owned by the current user.",
32
+ );
33
+ }
34
+
35
+ // Validate that the resolved path is owned by the current user
36
+ // to ensure security assumptions about file permissions (0o600/0o700) hold.
37
+ // Skip check if the directory does not exist yet.
38
+ try {
39
+ const stats = fs.statSync(resolved);
40
+ if (stats.uid !== os.userInfo().uid) {
41
+ throw new Error(
42
+ `userPiRoot: PI_TEAMS_HOME path "${resolved}" is not owned by the current user (uid=${os.userInfo().uid}, found uid=${stats.uid}). ` +
43
+ "This violates security assumptions about file permissions. Set PI_TEAMS_HOME to a path owned by the current user, or unset it to use the default.",
44
+ );
45
+ }
46
+ } catch (err: unknown) {
47
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
48
+ throw err;
49
+ }
50
+ // ENOENT from statSync means the directory was deleted between lstat and stat
51
+ // (race condition). This is acceptable — caller will handle.
52
+ }
53
+
54
+ return resolved;
13
55
  }
14
56
 
15
57
  const PROJECT_DIR_MARKERS = [".git", ".pi", ".crew", ".hg", ".svn", ".factory", ".omc"];
@@ -48,8 +90,34 @@ function hasProjectMarker(dir: string): boolean {
48
90
  return false;
49
91
  }
50
92
 
93
+ /** On Windows, resolve a path to its canonical long-name form.
94
+ * Uses realpathSync.native to get the \\?\ long-name path, then strips
95
+ * the prefix for path.relative compatibility. */
96
+ function canonicalizePath(p: string): string {
97
+ try {
98
+ const r = fs.realpathSync.native(p);
99
+ return r.startsWith("\\\\?\\") ? r.slice(4) : r;
100
+ } catch {
101
+ try { return fs.realpathSync(p); } catch { return p; }
102
+ }
103
+ }
104
+
51
105
  export function findRepoRoot(cwd: string): string | undefined {
52
- const startKey = path.resolve(cwd);
106
+ // Resolve symlinks before walking to prevent malicious symlinks from bypassing
107
+ // home/temp boundary checks. If the path doesn't exist (e.g., caller passed
108
+ // a non-existent CWD like /tmp/no-such-dir), fall back to the lexical path
109
+ // and let computeRepoRoot handle the rest. ENOENT here is common for
110
+ // newly-created test directories and shouldn't propagate as a crash.
111
+ let startKey: string;
112
+ try {
113
+ startKey = fs.realpathSync(cwd);
114
+ } catch (error) {
115
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
116
+ startKey = path.resolve(cwd);
117
+ } else {
118
+ throw error;
119
+ }
120
+ }
53
121
  const cached = projectRootCache.get(startKey);
54
122
  if (cached && Date.now() - cached.cachedAt < PROJECT_ROOT_CACHE_TTL_MS) {
55
123
  // Re-insert to refresh LRU position.
@@ -82,18 +150,25 @@ function computeRepoRoot(start: string): string | undefined {
82
150
  }
83
151
 
84
152
  export function projectPiRoot(cwd: string): string {
85
- return path.join(findRepoRoot(cwd) ?? cwd, ".pi");
153
+ const repoRoot = findRepoRoot(cwd) ?? cwd;
154
+ const piDir = path.join(repoRoot, ".pi");
155
+ // Use realpathSync to resolve any symlinks before returning to prevent
156
+ // config from being read from unexpected locations.
157
+ if (fs.existsSync(piDir)) return fs.realpathSync(piDir);
158
+ return piDir;
86
159
  }
87
160
 
88
161
  export function projectCrewRoot(cwd: string): string {
89
162
  const repoRoot = findRepoRoot(cwd) ?? cwd;
90
163
  const crewDir = path.join(repoRoot, ".crew");
91
164
  // Keep an existing .crew/ stable even when .pi/ exists for project config.
92
- if (fs.existsSync(crewDir)) return crewDir;
165
+ // Use canonicalizePath to get long-name form on Windows, matching
166
+ // what worktree operations and resolveRealContainedPath expect.
167
+ if (fs.existsSync(crewDir)) return canonicalizePath(crewDir);
93
168
  // Legacy reuse: if .pi/ already exists for the project, namespace under .pi/teams/
94
169
  // to avoid creating a parallel .crew/ alongside an existing pi project layout.
95
170
  const piDir = path.join(repoRoot, ".pi");
96
- if (fs.existsSync(piDir)) return path.join(piDir, "teams");
171
+ if (fs.existsSync(piDir)) return path.join(canonicalizePath(piDir), "teams");
97
172
  return crewDir;
98
173
  }
99
174
 
@@ -7,8 +7,13 @@
7
7
  export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]+?-----END [A-Z ]+PRIVATE KEY-----/g;
8
8
 
9
9
  // Linear-time secret key detection
10
+ // IMPORTANT: This function must maintain linear-time guarantees.
11
+ // The fast-path regex uses simple string alternatives with anchors only (no quantifiers),
12
+ // and the linear scan iterates through characters once. If either path is replaced with
13
+ // a more complex regex, catastrophic backtracking (ReDoS) could result.
14
+ // Any modifications must preserve O(n) complexity where n = keyName.length.
10
15
  export function isSecretKey(keyName: string): boolean {
11
- // Fast path: common secret key names
16
+ // Fast path: common secret key names (safe anchored regex, no backtracking)
12
17
  const lower = keyName.toLowerCase();
13
18
  if (/^(token|apikey|api_key|password|secret|credential|authorization|privatekey|private_key)$/.test(lower)) {
14
19
  return true;
@@ -16,30 +16,299 @@ export function resolveContainedPath(baseDir: string, targetPath: string): strin
16
16
  }
17
17
  const base = path.resolve(baseDir);
18
18
  const resolved = path.isAbsolute(targetPath) ? path.resolve(targetPath) : path.resolve(base, targetPath);
19
- 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);
20
28
  if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
21
29
  return resolved;
22
30
  }
23
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
+ */
24
122
  export function resolveRealContainedPath(baseDir: string, targetPath: string): string {
123
+ if (targetPath.includes('\0')) {
124
+ throw new Error(`Security: path contains null byte`);
125
+ }
25
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
+ }
26
161
  let realBase: string;
27
- let realTarget: string;
28
162
  try {
29
- realBase = fs.realpathSync.native(baseDir);
30
- } catch (baseError) {
31
- 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)}`);
32
237
  }
238
+
239
+ let realTarget: string;
33
240
  try {
34
- realTarget = fs.realpathSync.native(resolved);
241
+ // Use regular realpathSync (not .native) to preserve input path form.
242
+ realTarget = fs.realpathSync(resolved);
35
243
  } catch (targetError) {
36
244
  if ((targetError as NodeJS.ErrnoException).code === "ENOENT") {
37
- 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;
38
249
  }
39
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}`);
40
311
  }
41
- const relative = path.relative(realBase, realTarget);
42
- if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
43
312
  return realTarget;
44
313
  }
45
314
 
@@ -47,7 +316,9 @@ export function resolveContainedRelativePath(baseDir: string, relativePath: stri
47
316
  if (relativePath.includes('\0')) {
48
317
  throw new Error(`Security: path contains null byte: ${kind}`);
49
318
  }
50
- const normalized = relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
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}`);
51
322
  if (!normalized || normalized.split("/").some((segment) => segment === "..") || path.isAbsolute(normalized)) throw new Error(`Invalid ${kind}: ${relativePath}`);
52
323
  return resolveContainedPath(baseDir, path.resolve(baseDir, normalized));
53
324
  }