pi-crew 0.11.44 → 0.11.45

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.
@@ -1,25 +1,12 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
3
- import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
2
+ import { buildGitSafeEnv, hardenGitArgs } from "../worktree/git-hardening.ts";
4
3
 
5
- // Read-only git operations only (rev-list, rev-parse, log). Sanitize env to
6
- // avoid leaking API keys/tokens to any git hook/alias/credential-helper.
7
- // C-1 fix (code-review 2026-06-23): previously inherited the full parent env.
8
- const GIT_SAFE_ENV = sanitizeEnvSecrets(process.env, {
9
- allowList: [
10
- "PATH",
11
- "HOME",
12
- "USER",
13
- ...WINDOWS_ESSENTIAL_ENV_VARS,
14
- "SHELL",
15
- "TERM",
16
- "LANG",
17
- "LC_ALL",
18
- "GIT_CONFIG_GLOBAL",
19
- "GIT_CONFIG_SYSTEM",
20
- "GIT_EXEC_PATH",
21
- ],
22
- });
4
+ // Read-only git operations only (rev-list, rev-parse, log). C-1 fix
5
+ // (code-review 2026-06-23): never inherit the full parent env — API keys
6
+ // must not reach git hooks/aliases/credential-helpers. R-A6 (MED-1
7
+ // uniformity): env + argv now come from the shared git-hardening helpers
8
+ // (sanitizeEnvSecrets allowList, GIT_CONFIG_NOSYSTEM=1, config-injection
9
+ // overrides) instead of a drifting local GIT_SAFE_ENV copy.
23
10
 
24
11
  export type BranchFreshnessStatus = "fresh" | "stale" | "diverged" | "unknown";
25
12
  export type StaleBranchPolicy = "warn" | "block" | "auto_rebase" | "auto_merge_forward";
@@ -36,11 +23,11 @@ export interface BranchFreshness {
36
23
  }
37
24
 
38
25
  function git(cwd: string, args: string[]): string {
39
- return execFileSync("git", args, {
26
+ return execFileSync("git", hardenGitArgs(args), {
40
27
  cwd,
28
+ env: buildGitSafeEnv(),
41
29
  encoding: "utf-8",
42
30
  stdio: ["ignore", "pipe", "pipe"],
43
- env: GIT_SAFE_ENV,
44
31
  windowsHide: true,
45
32
  }).trim();
46
33
  }
@@ -4,10 +4,15 @@ import * as path from "node:path";
4
4
  import { DEFAULT_PATHS } from "../config/defaults.ts";
5
5
  import { writeArtifact } from "../state/stores/artifact-store.ts";
6
6
  import type { TeamRunManifest } from "../state/types.ts";
7
- import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
8
- import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
9
7
  import { logInternalError } from "../utils/internal-error.ts";
10
8
  import { projectCrewRoot } from "../utils/paths.ts";
9
+ import {
10
+ assertWorktreePointerTrusted,
11
+ buildGitSafeEnv,
12
+ hardenGitArgs,
13
+ resolveTrustedGitDir,
14
+ verifyWorktreeGitdirPointer,
15
+ } from "./git-hardening.ts";
11
16
 
12
17
  export interface WorktreeCleanupResult {
13
18
  removed: string[];
@@ -17,39 +22,16 @@ export interface WorktreeCleanupResult {
17
22
  committedBranches: string[];
18
23
  }
19
24
 
20
- // SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
21
- // Git operations do not need PI_CREW_* execution-control vars.
22
- const GIT_SAFE_ENV = {
23
- ...sanitizeEnvSecrets(process.env, {
24
- allowList: [
25
- "PATH",
26
- "HOME",
27
- "USER",
28
- ...WINDOWS_ESSENTIAL_ENV_VARS,
29
- "SHELL",
30
- "TERM",
31
- "LANG",
32
- "LC_ALL",
33
- "LC_COLLATE",
34
- "LC_CTYPE",
35
- "LC_MESSAGES",
36
- "XDG_CONFIG_HOME",
37
- "XDG_DATA_HOME",
38
- "XDG_CACHE_HOME",
39
- "NVM_BIN",
40
- "NVM_DIR",
41
- "NODE_PATH",
42
- "GIT_CONFIG_GLOBAL",
43
- "GIT_CONFIG_SYSTEM",
44
- "GIT_AUTHOR_NAME",
45
- "GIT_AUTHOR_EMAIL",
46
- "GIT_COMMITTER_NAME",
47
- "GIT_COMMITTER_EMAIL",
48
- ],
49
- }),
50
- LANG: "C",
51
- LC_ALL: "C",
52
- };
25
+ // MED-1 / R-A4: shared sanitized env for every git spawn (see
26
+ // buildGitSafeEnv in git-hardening.ts — allowList-based secret stripping,
27
+ // GIT_CONFIG_NOSYSTEM=1, GIT_CONFIG_SYSTEM dropped from the allowlist,
28
+ // GIT_CONFIG_GLOBAL kept for commit identity with the documented residual
29
+ // that trusted-config filter definitions remain executable).
30
+ // Built per call (not module-load) so late env changes (tests, config reload)
31
+ // are honored.
32
+ function gitSafeEnv(): Record<string, string> {
33
+ return buildGitSafeEnv();
34
+ }
53
35
 
54
36
  function sanitizeBranchPart(value: string): string {
55
37
  return (
@@ -66,12 +48,20 @@ function sanitizeFilename(value: string): string {
66
48
  }
67
49
 
68
50
  function git(cwd: string, args: string[]): string {
51
+ // R-A3: re-verify the gitdir pointer before EVERY command whose cwd sits
52
+ // inside a linked worktree (TOCTOU: the entry-level guard in
53
+ // cleanupRunWorktrees can be invalidated by a still-live worker flipping
54
+ // the `.git` pointer mid-chain). Throws OUTSIDE the execFile try so the
55
+ // error propagates unwrapped into the existing "corrupted" handling paths.
56
+ assertWorktreePointerTrusted(cwd);
69
57
  try {
70
- return execFileSync("git", args, {
58
+ // MED-1: harden EVERY git invocation (config-injection overrides +
59
+ // diff/commit neutralizers). See src/worktree/git-hardening.ts.
60
+ return execFileSync("git", hardenGitArgs(args), {
71
61
  cwd,
62
+ env: gitSafeEnv(),
72
63
  encoding: "utf-8",
73
64
  stdio: ["ignore", "pipe", "pipe"],
74
- env: GIT_SAFE_ENV,
75
65
  windowsHide: true,
76
66
  }).trim();
77
67
  } catch (error) {
@@ -116,6 +106,9 @@ export function cleanupRunWorktrees(
116
106
  committedBranches: [],
117
107
  };
118
108
  if (!fs.existsSync(worktreeRoot)) return result;
109
+ // MED-1: resolve the TRUSTED git common dir once (from the orchestrator-owned
110
+ // main repo — never from inside a worktree) for the per-entry gitdir guard.
111
+ const trustedGitDir = resolveTrustedGitDir(manifest.cwd);
119
112
 
120
113
  // M3 fix: use withFileTypes to avoid race between readdirSync and statSync.
121
114
  // Rely on Dirent.isDirectory() instead of a separate statSync to eliminate TOCTOU window.
@@ -127,6 +120,46 @@ export function cleanupRunWorktrees(
127
120
  if (options.signal?.aborted) break;
128
121
  const worktreePath = path.join(worktreeRoot, entry.name);
129
122
  if (options.signal?.aborted) break;
123
+ // MED-1 gitdir-hijack guard: a worker can rewrite the worktree `.git`
124
+ // pointer file to an attacker-controlled directory whose poisoned `config`
125
+ // would execute hooks/filters/fsmonitor in the orchestrator context. Before
126
+ // ANY git runs with cwd inside the worktree, the pointer must resolve to the
127
+ // git-registered admin dir (see src/worktree/git-hardening.ts). On mismatch
128
+ // NO git runs from inside; removal is only attempted (force) from the
129
+ // trusted main-repo cwd, and the worktree is otherwise preserved.
130
+ const gitdirCheck = trustedGitDir
131
+ ? verifyWorktreeGitdirPointer(worktreePath, trustedGitDir)
132
+ : { ok: false, reason: `main repository git dir not found under ${manifest.cwd}` };
133
+ if (!gitdirCheck.ok) {
134
+ logInternalError(
135
+ "cleanup.worktree.gitdirMismatch",
136
+ new Error(`Refusing to run git inside ${worktreePath}: ${gitdirCheck.reason}`),
137
+ `runId=${manifest.runId}`,
138
+ );
139
+ if (options.force) {
140
+ let removed = false;
141
+ try {
142
+ git(manifest.cwd, ["worktree", "remove", "--force", worktreePath]);
143
+ removed = true;
144
+ } catch {
145
+ try {
146
+ fs.rmSync(worktreePath, { recursive: true, force: true });
147
+ removed = true;
148
+ } catch {
149
+ /* keep as preserved */
150
+ }
151
+ }
152
+ if (removed) {
153
+ result.removed.push(worktreePath);
154
+ continue;
155
+ }
156
+ }
157
+ result.preserved.push({
158
+ path: worktreePath,
159
+ reason: `worktree corrupted — gitdir pointer check failed (${gitdirCheck.reason}); no git executed inside; inspect manually or remove with force=true`,
160
+ });
161
+ continue;
162
+ }
130
163
  const dirty = isDirty(worktreePath);
131
164
  const branchName = `pi-crew/${manifest.runId}/${sanitizeBranchPart(entry.name)}`;
132
165
  const safeBranchName = sanitizeBranchPart(entry.name);
@@ -156,13 +189,9 @@ export function cleanupRunWorktrees(
156
189
  try {
157
190
  // Issue 2 fix: verify status before and after add to ensure atomicity
158
191
  const statusBefore = git(worktreePath, ["status", "--porcelain"]);
159
- execFileSync("git", ["add", "-A"], {
160
- cwd: worktreePath,
161
- encoding: "utf-8",
162
- stdio: ["ignore", "pipe", "pipe"],
163
- env: GIT_SAFE_ENV,
164
- windowsHide: true,
165
- });
192
+ // MED-1: routed through the hardened git() helper (fsmonitor/hooksPath
193
+ // disabled) — a worker-planted config must not execute on `git add`.
194
+ git(worktreePath, ["add", "-A"]);
166
195
  // Verify no unexpected changes were added (only staged the original dirty files)
167
196
  const statusAfterAdd = git(worktreePath, ["status", "--porcelain"]);
168
197
  if (statusAfterAdd !== statusBefore) {
@@ -177,37 +206,21 @@ export function cleanupRunWorktrees(
177
206
  if (safeDesc.includes("\n")) {
178
207
  safeDesc = safeDesc.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/g, " ");
179
208
  }
180
- execFileSync("git", ["commit", "-m", `pi-crew: ${safeDesc}`], {
181
- cwd: worktreePath,
182
- encoding: "utf-8",
183
- stdio: ["ignore", "pipe", "pipe"],
184
- env: GIT_SAFE_ENV,
185
- windowsHide: true,
186
- });
209
+ // MED-1: --no-verify + hooksPath= neutralize every commit hook
210
+ // (pre-commit/commit-msg/prepare-commit-msg/post-commit).
211
+ git(worktreePath, ["commit", "-m", `pi-crew: ${safeDesc}`]);
187
212
  // Issue 1 fix: check signal before branch creation
188
213
  if (options.signal?.aborted) break;
189
214
  // Create branch in the main repo pointing to this worktree's HEAD
190
215
  let branchError: Error | null = null;
191
216
  try {
192
- execFileSync("git", ["branch", branchName], {
193
- cwd: worktreePath,
194
- encoding: "utf-8",
195
- stdio: ["ignore", "pipe", "pipe"],
196
- env: GIT_SAFE_ENV,
197
- windowsHide: true,
198
- });
217
+ git(worktreePath, ["branch", branchName]);
199
218
  } catch (err) {
200
219
  branchError = err instanceof Error ? err : new Error(String(err));
201
220
  // Branch already exists — use timestamp suffix
202
221
  const tsBranch = `${branchName}-${Date.now()}`;
203
222
  try {
204
- execFileSync("git", ["branch", tsBranch], {
205
- cwd: worktreePath,
206
- encoding: "utf-8",
207
- stdio: ["ignore", "pipe", "pipe"],
208
- env: GIT_SAFE_ENV,
209
- windowsHide: true,
210
- });
223
+ git(worktreePath, ["branch", tsBranch]);
211
224
  } catch (err2) {
212
225
  // Both branch attempts failed — accumulate error for outer catch
213
226
  const err2_msg = err2 instanceof Error ? err2.message : String(err2);