pi-crew 0.9.0 → 0.9.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,100 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.9.1] — Windows essentials fix + cross-platform CI green (2026-06-22)
4
+
5
+ Patch release. No new features. Fixes a real Windows bug reported by a user,
6
+ plus the cross-platform CI failures that followed.
7
+
8
+ ### fix(windows): `${APPDATA}` npm-global resolution failure (root cause)
9
+
10
+ Reported symptom (Windows): running pi-crew created a phantom literal
11
+ `${APPDATA}/npm/` directory in the project root (containing `node_modules`,
12
+ `pi-crew`, `pi-crew.cmd`, `pi-crew.ps1`) and leaked a literal `${APPDATA}`
13
+ line into `.gitignore`.
14
+
15
+ Root cause: pi-crew's subprocess env sanitization used explicit allowlists
16
+ that stripped **all Windows-essential env vars** (`APPDATA`, `LOCALAPPDATA`,
17
+ `USERPROFILE`, `SystemRoot`, `ComSpec`, `TEMP`, `TMP`). When a child pi
18
+ process (or npm inside it) tried to resolve the npm-global prefix on Windows,
19
+ it used `%APPDATA%` (cmd expansion) / `${APPDATA}` (bash expansion), but
20
+ `APPDATA` was missing from the env — so the shell left the literal
21
+ `${APPDATA}` in place and operations created/ignored paths under that
22
+ literal name.
23
+
24
+ Fix: added the 7 Windows essentials to all 7 subprocess env allowlists
25
+ (child-pi, async-runner, verification-gates, post-checks, iteration-hooks,
26
+ worktree/cleanup, worktree/worktree-manager). (commit `a7ddc50`)
27
+
28
+ ### refactor(env): centralize Windows essentials + regression guard
29
+
30
+ The same 7 vars were duplicated inline across 9 call sites — easy to forget
31
+ on a new allowlist, with nothing preventing a future site from omitting them.
32
+
33
+ - New single source of truth: `WINDOWS_ESSENTIAL_ENV_VARS` in
34
+ `src/utils/env-allowlist.ts` (with full root-cause documentation).
35
+ - All 9 call sites now spread the constant instead of inlining (net −42/+19
36
+ lines, behavior unchanged).
37
+ - New regression test `test/unit/env-allowlist.test.ts`: scans ALL
38
+ `src/**/*.ts` files and fails if any hardcodes the 7 vars inline (the only
39
+ allowed location is the constant file). This catches any new allowlist that
40
+ forgets the constant — the exact regression that caused the bug.
41
+ (commit `6a0284c`)
42
+
43
+ ### fix(ci): cross-platform CI green (ubuntu + macOS + Windows)
44
+
45
+ Three distinct containment/path bugs that only surfaced on non-ubuntu CI:
46
+
47
+ 1. **Windows 8.3 short-name paths** — `resolveWindowsCanonical()` used
48
+ non-native `realpathSync`, preserving the `RUNNER~1` vs `runneradmin`
49
+ form mismatch. A legitimately-contained dynamic workflow file was
50
+ rejected as "outside the allowed directories". Fixed by using
51
+ `realpathSync.native` (canonical long-name form) as the primary resolver.
52
+ (commit `e9e7137`)
53
+
54
+ 2. **ESM `file://` URLs** — two integration tests passed raw Windows paths
55
+ (`D:\…`) to native `import()`, which Node rejects on Windows
56
+ (`ERR_UNSUPPORTED_ESM_URL_SCHEME: protocol 'd:'`). Wrapped with
57
+ `pathToFileURL(…).href`. (commit `e9e7137`)
58
+
59
+ 3. **macOS symlink-ancestor** — `isSymlinkSafePath()` walked up the temp
60
+ path and hit `/var` (a symlink → `/private/var`). The old check compared
61
+ the resolved `/private/var` against the tmpdir
62
+ `/private/var/folders/…/T` — `/private/var` is an **ancestor**, not a
63
+ descendant, so it was wrongly rejected (5 macOS worker-atomic-writer
64
+ failures). Fixed by accepting a symlink whose target is a safe root, is
65
+ UNDER a safe root, OR is an ANCESTOR of a safe root. Added two behavioral
66
+ regression tests (symlink-ancestor accept + symlink-attack reject).
67
+ (commit `e9e7137`)
68
+
69
+ 4. **macOS `/var` containment** — `resolveContainedPath()` only
70
+ canonicalized paths on win32; on POSIX it compared raw paths, so base
71
+ (`/private/var`) vs target (`/var`) diverged → false "outside" rejection
72
+ (macOS dwf-setresult failure). Added platform-agnostic
73
+ `resolveCanonicalPath()`. Added a darwin-only regression test for the
74
+ real `/var` divergence. (commit `4821bb1`)
75
+
76
+ 5. **Windows wakeup timing** — `subagent-manager` polls the child run
77
+ manifest every 1000ms. On the slower Windows CI runner, child-process
78
+ spawn + first poll exceeded the test's 10s deadline (failed at 11.6s).
79
+ Bumped the mock-test deadline to 30s. (commit `4821bb1`)
80
+
81
+ ### Verification
82
+
83
+ - tsc: 0
84
+ - Full test suite: 5207 tests, 0 fail on **all three** platforms (ubuntu,
85
+ macOS, Windows)
86
+ - CI run `27955398241`: success across ubuntu-latest, macos-latest,
87
+ windows-latest
88
+ - Regression tests added: env-allowlist scan, worker-atomic-writer symlink
89
+ ancestor/attack, safe-paths darwin `/var` divergence
90
+
91
+ ### Breaking changes
92
+
93
+ None. All fixes are additive or behavior-preserving. Windows users who hit
94
+ the `${APPDATA}` bug should upgrade.
95
+
96
+ ---
97
+
3
98
  ## [v0.9.0] — goal loops + dynamic workflows (2026-06-18)
4
99
 
5
100
  Two new features, both built on a shared `runKind` background-dispatch discriminator.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
6
7
  import { logInternalError } from "../utils/internal-error.ts";
7
8
  import { appendEvent } from "../state/event-log.ts";
8
9
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
@@ -183,6 +184,8 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
183
184
  "XDG_DATA_HOME",
184
185
  "XDG_CACHE_HOME",
185
186
  "XDG_RUNTIME_DIR",
187
+ // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
188
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
186
189
  "NVM_BIN",
187
190
  "NVM_DIR",
188
191
  "NVM_INC",
@@ -1,6 +1,7 @@
1
1
  import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
4
5
  import type { AgentConfig } from "../agents/agent-config.ts";
5
6
  import type { WorkerExitStatus } from "../state/types.ts";
6
7
  import { buildPiWorkerArgs, checkCrewDepth, cleanupTempDir } from "./pi-args.ts";
@@ -245,6 +246,8 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): S
245
246
  "XDG_DATA_HOME",
246
247
  "XDG_CACHE_HOME",
247
248
  "XDG_RUNTIME_DIR",
249
+ // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
250
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
248
251
  "NVM_BIN",
249
252
  "NVM_DIR",
250
253
  "NVM_INC",
@@ -7,6 +7,7 @@
7
7
  import { spawn } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
10
11
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
11
12
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
12
13
  import { DENIED_METRIC_NAMES } from "./metric-parser.ts";
@@ -171,7 +172,7 @@ export async function runIterationHook(
171
172
  const { command, args } = resolveShellForScript(resolvedScript);
172
173
  const child = spawn(command, args, {
173
174
  cwd: payload.cwd,
174
- env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_CREW_*"] }), PI_CREW_HOOK: "1" },
175
+ env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL", "PI_CREW_*"] }), PI_CREW_HOOK: "1" },
175
176
  stdio: ["pipe", "pipe", "pipe"],
176
177
  });
177
178
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
8
  import * as path from "node:path";
9
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
9
10
  import { resolveShellForScript } from "../utils/resolve-shell.ts";
10
11
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
11
12
 
@@ -94,7 +95,7 @@ export async function runPostCheck(config: PostCheckConfig, cwd: string): Promis
94
95
  timeout: timeoutMs,
95
96
  encoding: "utf-8",
96
97
  maxBuffer: 10 * 1024 * 1024, // 10 MB
97
- env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_CREW_*"] }), PI_CREW_POST_CHECK: "1" },
98
+ env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL", "PI_CREW_*"] }), PI_CREW_POST_CHECK: "1" },
98
99
  });
99
100
 
100
101
  const durationMs = Date.now() - startTime;
@@ -12,6 +12,7 @@
12
12
  import { spawn } from "node:child_process";
13
13
  import * as fs from "node:fs";
14
14
  import * as path from "node:path";
15
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
15
16
  import { writeArtifact } from "../state/artifact-store.ts";
16
17
  import { redactSecretString } from "../utils/redaction.ts";
17
18
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
@@ -48,6 +49,8 @@ const VERIFICATION_ENV_ALLOWLIST: readonly string[] = [
48
49
  "XDG_DATA_HOME",
49
50
  "XDG_CACHE_HOME",
50
51
  "XDG_RUNTIME_DIR",
52
+ // Windows essentials — see WINDOWS_ESSENTIAL_ENV_VARS (src/utils/env-allowlist.ts).
53
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
51
54
  "NVM_BIN",
52
55
  "NVM_DIR",
53
56
  "NVM_INC",
@@ -40,24 +40,38 @@ const crypto = require("node:crypto");
40
40
 
41
41
  function isSymlinkSafePath(filePath) {
42
42
  try {
43
- let currentPath = filePath;
43
+ // Safe roots: the system temp dir (resolved through any symlinks, e.g.
44
+ // macOS /var → /private/var) and the current project cwd.
45
+ var tmpReal;
46
+ try { tmpReal = fs.realpathSync(require("node:os").tmpdir()); } catch (e2) { tmpReal = require("node:os").tmpdir(); }
47
+ var cwd = process.cwd();
48
+ // Accept a symlink ancestor as safe if the resolved target (a) IS a safe
49
+ // root, (b) is UNDER a safe root (target is a descendant), or (c) is an
50
+ // ANCESTOR of a safe root (target is a parent dir of a safe root).
51
+ // Case (c) is essential on macOS: /var → /private/var is an ancestor of
52
+ // the tmpdir /private/var/folders/…, so it must be allowed.
53
+ var isSafeAncestor = function (real) {
54
+ var roots = [tmpReal, cwd];
55
+ return roots.some(function (root) {
56
+ return real === root
57
+ || real.indexOf(root + path.sep) === 0
58
+ || root.indexOf(real + path.sep) === 0;
59
+ });
60
+ };
61
+ var currentPath = filePath;
44
62
  while (currentPath !== path.dirname(currentPath)) {
45
- const dir = path.dirname(currentPath);
63
+ var dir = path.dirname(currentPath);
46
64
  try {
47
- const stat = fs.lstatSync(dir);
65
+ var stat = fs.lstatSync(dir);
48
66
  if (stat.isSymbolicLink()) {
49
- // Accept symlinks under /tmp (macOS /var/folders) and project dirs;
50
- // reject others. Mirrors atomic-write.ts policy for goal-loop paths.
51
- const real = fs.realpathSync(dir);
52
- if (!real.startsWith("/tmp/") && !real.startsWith(process.cwd())) {
53
- return false;
54
- }
67
+ var real = fs.realpathSync(dir);
68
+ if (!isSafeAncestor(real)) return false;
55
69
  }
56
- } catch { /* not found — fine */ }
70
+ } catch (e3) { /* not found — fine */ }
57
71
  currentPath = dir;
58
72
  }
59
73
  return true;
60
- } catch { return true; }
74
+ } catch (e) { return true; }
61
75
  }
62
76
 
63
77
  function syncAtomicWriteFile(filePath, content) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Windows-essential environment variables that MUST be present in every
3
+ * subprocess env allowlist in pi-crew.
4
+ *
5
+ * Without them, child processes on Windows cannot locate the npm-global prefix
6
+ * (`%APPDATA%\npm`), the user profile, system DLLs (SystemRoot), cmd.exe
7
+ * (ComSpec), or a writable temp dir.
8
+ *
9
+ * Regression root cause (v0.9.0): env allowlists stripped these vars, so child
10
+ * pi/npm resolved the npm-global prefix via the literal `%APPDATA%` (cmd) /
11
+ * `${APPDATA}` (bash) expansion — but APPDATA was missing from the env, so the
12
+ * shell left the literal `${APPDATA}` in place. A phantom `${APPDATA}/npm`
13
+ * directory appeared in the project root and a literal `${APPDATA}` line leaked
14
+ * into `.gitignore`.
15
+ *
16
+ * USAGE: spread this constant into every `allowList` array:
17
+ * `allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, ...]`
18
+ *
19
+ * The regression test `test/unit/env-allowlist.test.ts` enforces that no
20
+ * `src/` file hardcodes these vars inline — all sites MUST use this constant.
21
+ */
22
+ export const WINDOWS_ESSENTIAL_ENV_VARS: readonly string[] = [
23
+ "APPDATA",
24
+ "LOCALAPPDATA",
25
+ "USERPROFILE",
26
+ "SystemRoot",
27
+ "ComSpec",
28
+ "TEMP",
29
+ "TMP",
30
+ ];
@@ -16,32 +16,73 @@ 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
- // 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;
19
+ // Normalize BOTH paths to canonical form on ALL platforms. This resolves
20
+ // symlinks so that a base and target that refer to the same physical dir
21
+ // via different paths (Windows 8.3 short-name; macOS /var /private/var)
22
+ // compare equal. Without this, a legitimately-contained target under an
23
+ // OS-managed symlink is wrongly rejected as "outside" the base.
24
+ const baseNorm = resolveCanonicalPath(base);
25
+ const resolvedNorm = resolveCanonicalPath(resolved);
25
26
  const relative = process.platform === "win32"
26
27
  ? path.relative(baseNorm.toLowerCase(), resolvedNorm.toLowerCase())
27
- : path.relative(base, resolved);
28
+ : path.relative(baseNorm, resolvedNorm);
28
29
  if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Path is outside ${baseDir}: ${targetPath}`);
29
30
  return resolved;
30
31
  }
31
32
 
33
+ /**
34
+ * Platform-agnostic canonical path resolution. Resolves symlinks (via
35
+ * realpathSync) to normalize paths so the same physical location always
36
+ * yields the same string regardless of how it was referenced.
37
+ *
38
+ * - Windows: delegates to resolveWindowsCanonical (canonical LONG-NAME form,
39
+ * resolving 8.3 short-name aliases like RUNNER~1 → runneradmin).
40
+ * - POSIX: uses realpathSync, which resolves system symlinks such as the
41
+ * macOS /var → /private/var mapping.
42
+ *
43
+ * For non-existent paths (write targets), walks up to the deepest existing
44
+ * ancestor and joins the remaining components, so the canonical prefix is
45
+ * still comparable.
46
+ */
47
+ function resolveCanonicalPath(p: string): string {
48
+ if (process.platform === "win32") return resolveWindowsCanonical(p);
49
+ try {
50
+ return fs.realpathSync(p);
51
+ } catch {
52
+ const parts: string[] = [];
53
+ let current = p;
54
+ while (current !== path.dirname(current)) {
55
+ try {
56
+ const real = fs.realpathSync(current);
57
+ let acc = real;
58
+ for (let i = parts.length - 1; i >= 0; i--) acc = path.join(acc, parts[i]);
59
+ return acc;
60
+ } catch { /* keep walking up */ }
61
+ parts.push(path.basename(current));
62
+ current = path.dirname(current);
63
+ }
64
+ return p;
65
+ }
66
+ }
67
+
32
68
  /**
33
69
  * On Windows, resolve a path to its canonical (long-name) form.
34
70
  * Walks up ancestors until finding one that exists, then joins back down.
35
71
  * This handles paths where intermediate directories don't exist yet but
36
72
  * their ancestors do (and may use short-name aliases).
73
+ *
74
+ * Uses fs.realpathSync.native (canonical long-name form) as the primary
75
+ * resolver, falling back to non-native realpathSync if .native fails.
37
76
  */
38
77
  function resolveWindowsCanonical(p: string): string {
39
78
  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);
79
+ // Use the NATIVE realpath to resolve to canonical LONG-NAME form.
80
+ // On Windows, fs.realpathSync.native resolves 8.3 short-name aliases
81
+ // (e.g. RUNNER~1) to long-name (runneradmin). This is essential for
82
+ // containment checks: base (from cwd, often long-name) and target
83
+ // (from os.tmpdir()/mkdtempSync, often short-name) must normalize to
84
+ // the SAME form or a contained target is wrongly rejected as "outside".
85
+ let real = fs.realpathSync.native(p);
45
86
  // Guard against NTFS internal paths (e.g. C:\$Extend\$Deleted)
46
87
  if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
47
88
  return real;
@@ -56,8 +97,8 @@ function resolveWindowsCanonical(p: string): string {
56
97
  let current = p;
57
98
  while (current !== path.dirname(current)) {
58
99
  try {
59
- // Use non-native to preserve input path form
60
- let real = fs.realpathSync(current);
100
+ let real: string;
101
+ try { real = fs.realpathSync.native(current); } catch { real = fs.realpathSync(current); }
61
102
  // Guard against NTFS internal paths
62
103
  if (real.includes("$Extend") || real.includes("$Deleted")) throw new Error("NTFS internal path");
63
104
  // Found existing ancestor — join with remaining parts in reverse order
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
4
5
  import type { TeamRunManifest } from "../state/types.ts";
5
6
  import { writeArtifact } from "../state/artifact-store.ts";
6
7
  import { projectCrewRoot } from "../utils/paths.ts";
@@ -18,7 +19,7 @@ export interface WorktreeCleanupResult {
18
19
 
19
20
  // SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
20
21
  // 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
+ const GIT_SAFE_ENV = { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "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
 
23
24
  function sanitizeBranchPart(value: string): string {
24
25
  return value.toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").replace(/^-+|-+$/g, "") || "task";
@@ -1,6 +1,7 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
4
5
  import { loadConfig } from "../config/config.ts";
5
6
  import { projectCrewRoot } from "../utils/paths.ts";
6
7
  import { DEFAULT_PATHS } from "../config/defaults.ts";
@@ -27,7 +28,7 @@ export interface WorktreeDiffStat {
27
28
  function git(cwd: string, args: string[]): string {
28
29
  // SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
29
30
  // 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();
31
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", ...WINDOWS_ESSENTIAL_ENV_VARS, "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();
31
32
  }
32
33
 
33
34
  // Dots are removed from branch names since they are used in path construction,
@@ -182,7 +183,7 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
182
183
  timeout: cfg.setupHookTimeoutMs ?? 30_000,
183
184
  shell: false, // cmd.exe /c handles batch files safely
184
185
  env: sanitizeEnvSecrets(process.env, {
185
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
186
+ allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL"],
186
187
  }),
187
188
  windowsHide: true,
188
189
  })
@@ -193,7 +194,7 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
193
194
  timeout: cfg.setupHookTimeoutMs ?? 30_000,
194
195
  shell: false,
195
196
  env: sanitizeEnvSecrets(process.env, {
196
- allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL"],
197
+ allowList: ["PATH", "HOME", ...WINDOWS_ESSENTIAL_ENV_VARS, "TMPDIR", "LANG", "LC_ALL"],
197
198
  }),
198
199
  windowsHide: true,
199
200
  });