pi-crew 0.11.44 → 0.11.46

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.
@@ -11,6 +11,14 @@ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
11
11
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
12
12
  import { logInternalError } from "../utils/internal-error.ts";
13
13
  import { projectCrewRoot } from "../utils/paths.ts";
14
+ import {
15
+ assertWorktreePointerTrusted,
16
+ buildGitSafeEnv,
17
+ hardenGitArgs,
18
+ resolveTrustedGitDir,
19
+ verifyWorktreeGitdirPointer,
20
+ worktreeTrustCheck,
21
+ } from "./git-hardening.ts";
14
22
 
15
23
  export interface PreparedTaskWorkspace {
16
24
  cwd: string;
@@ -46,85 +54,29 @@ export interface WorktreeDiffStat {
46
54
  function git(cwd: string, args: string[]): string {
47
55
  // SECURITY: PI_* and PI_CREW_* wildcards removed — they could match secret vars like PI_PASSWORD.
48
56
  // Git operations do not need PI_CREW_* execution-control vars.
49
- return execFileSync("git", args, {
57
+ // MED-1: hardened — config-injection overrides via hardenGitArgs (git-hardening.ts).
58
+ // R-A3: re-verify the gitdir pointer before EVERY command whose cwd sits
59
+ // inside a linked worktree (TOCTOU: a live worker can flip the `.git`
60
+ // pointer after an entry-level check passed). The assert throws OUTSIDE the
61
+ // execFile try so callers land in their "worktree corrupted" handling.
62
+ assertWorktreePointerTrusted(cwd);
63
+ return execFileSync("git", hardenGitArgs(args), {
50
64
  cwd,
51
65
  encoding: "utf-8",
52
66
  stdio: ["ignore", "pipe", "pipe"],
53
- env: {
54
- ...sanitizeEnvSecrets(process.env, {
55
- allowList: [
56
- "PATH",
57
- "HOME",
58
- "USER",
59
- ...WINDOWS_ESSENTIAL_ENV_VARS,
60
- "SHELL",
61
- "TERM",
62
- "LANG",
63
- "LC_ALL",
64
- "LC_COLLATE",
65
- "LC_CTYPE",
66
- "LC_MESSAGES",
67
- "XDG_CONFIG_HOME",
68
- "XDG_DATA_HOME",
69
- "XDG_CACHE_HOME",
70
- "NVM_BIN",
71
- "NVM_DIR",
72
- "NODE_PATH",
73
- "GIT_CONFIG_GLOBAL",
74
- "GIT_CONFIG_SYSTEM",
75
- "GIT_AUTHOR_NAME",
76
- "GIT_AUTHOR_EMAIL",
77
- "GIT_COMMITTER_NAME",
78
- "GIT_COMMITTER_EMAIL",
79
- ],
80
- }),
81
- LANG: "en_US.UTF-8",
82
- LC_ALL: "en_US.UTF-8",
83
- },
67
+ env: buildGitSafeEnv("en_US.UTF-8"),
84
68
  windowsHide: true,
85
69
  }).trim();
86
70
  }
87
71
 
88
- /** Build the sanitized env object shared by all git operations. */
89
- function gitEnv(): Record<string, string> {
90
- return {
91
- ...sanitizeEnvSecrets(process.env, {
92
- allowList: [
93
- "PATH",
94
- "HOME",
95
- "USER",
96
- ...WINDOWS_ESSENTIAL_ENV_VARS,
97
- "SHELL",
98
- "TERM",
99
- "LANG",
100
- "LC_ALL",
101
- "LC_COLLATE",
102
- "LC_CTYPE",
103
- "LC_MESSAGES",
104
- "XDG_CONFIG_HOME",
105
- "XDG_DATA_HOME",
106
- "XDG_CACHE_HOME",
107
- "NVM_BIN",
108
- "NVM_DIR",
109
- "NODE_PATH",
110
- "GIT_CONFIG_GLOBAL",
111
- "GIT_CONFIG_SYSTEM",
112
- "GIT_AUTHOR_NAME",
113
- "GIT_AUTHOR_EMAIL",
114
- "GIT_COMMITTER_NAME",
115
- "GIT_COMMITTER_EMAIL",
116
- ],
117
- }),
118
- LANG: "en_US.UTF-8",
119
- LC_ALL: "en_US.UTF-8",
120
- };
121
- }
122
-
123
72
  async function gitAsync(cwd: string, args: string[]): Promise<string> {
124
- const { stdout } = await execFileAsync("git", args, {
73
+ // MED-1: same hardening as the sync git() helper (see git-hardening.ts).
74
+ // R-A3: per-command gitdir re-verify (see git() above).
75
+ assertWorktreePointerTrusted(cwd);
76
+ const { stdout } = await execFileAsync("git", hardenGitArgs(args), {
125
77
  cwd,
126
78
  encoding: "utf-8",
127
- env: gitEnv(),
79
+ env: buildGitSafeEnv("en_US.UTF-8"),
128
80
  windowsHide: true,
129
81
  });
130
82
  return stdout.trim();
@@ -481,14 +433,11 @@ function branchExists(repoRoot: string, branch: string): { local: boolean; remot
481
433
  logInternalError("worktree.branch-local.sync", error, `branch=${branch}`, "debug");
482
434
  }
483
435
  if (local) return { local: true, remoteOnly: false };
484
- // Check remote-tracking branch
436
+ // Check remote-tracking branch (R-A7: routed through the hardened git()
437
+ // helper — sanitized env + hardenGitArgs uniformity; repoRoot cwd is a
438
+ // trusted main-repo context, no exec vector was reachable raw either).
485
439
  try {
486
- const out = execFileSync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], {
487
- cwd: repoRoot,
488
- encoding: "utf-8",
489
- stdio: ["ignore", "pipe", "pipe"],
490
- windowsHide: true,
491
- }).trim();
440
+ const out = git(repoRoot, ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`]);
492
441
  return { local: false, remoteOnly: out.length > 0 };
493
442
  } catch {
494
443
  return { local: false, remoteOnly: false };
@@ -497,10 +446,9 @@ function branchExists(repoRoot: string, branch: string): { local: boolean; remot
497
446
 
498
447
  function pruneStaleWorktrees(repoRoot: string): void {
499
448
  try {
500
- execFileSync("git", ["worktree", "prune"], {
501
- cwd: repoRoot,
502
- stdio: "ignore",
503
- });
449
+ // R-A7 (uniformity): previously a raw execFileSync with the FULL
450
+ // process.env — now the hardened helper (sanitized env; repoRoot cwd).
451
+ git(repoRoot, ["worktree", "prune"]);
504
452
  } catch {
505
453
  /* best-effort */
506
454
  }
@@ -534,15 +482,9 @@ async function branchExistsAsync(repoRoot: string, branch: string): Promise<{ lo
534
482
  logInternalError("worktree.branch-local.async", error, `branch=${branch}`, "debug");
535
483
  }
536
484
  if (local) return { local: true, remoteOnly: false };
537
- // Check remote-tracking branch
485
+ // Check remote-tracking branch (R-A7: hardened helper, see branchExists).
538
486
  try {
539
- const out = (
540
- await execFileAsync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`], {
541
- cwd: repoRoot,
542
- encoding: "utf-8",
543
- windowsHide: true,
544
- })
545
- ).stdout.trim();
487
+ const out = await gitAsync(repoRoot, ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`]);
546
488
  return { local: false, remoteOnly: out.length > 0 };
547
489
  } catch {
548
490
  return { local: false, remoteOnly: false };
@@ -565,10 +507,8 @@ async function pruneStaleWorktreesAsync(repoRoot: string): Promise<void> {
565
507
  if (inFlight) return inFlight;
566
508
  const p = (async () => {
567
509
  try {
568
- await execFileAsync("git", ["worktree", "prune"], {
569
- cwd: repoRoot,
570
- windowsHide: true,
571
- });
510
+ // R-A7 (uniformity): sanitized env via the hardened helper.
511
+ await gitAsync(repoRoot, ["worktree", "prune"]);
572
512
  } catch {
573
513
  /* best-effort */
574
514
  }
@@ -1045,6 +985,21 @@ export function prepareTaskWorkspace(
1045
985
  worktreeExists = false;
1046
986
  }
1047
987
  if (worktreeExists) {
988
+ // MED-1 gitdir-hijack guard: before ANY git runs with cwd inside this
989
+ // worktree, its `.git` pointer must resolve to the git-REGISTERED admin dir
990
+ // (registry: <commonGitDir>/worktrees/<name>/gitdir — trusted main-repo state).
991
+ // A worker with write access to the worktree can repoint the pointer at a
992
+ // poisoned gitdir; fail closed instead of executing its config.
993
+ const commonGitDir = resolveTrustedGitDir(repoRoot);
994
+ if (!commonGitDir) {
995
+ throw new Error(`Cannot resolve the trusted git dir for ${repoRoot}; refusing to reuse worktree at ${worktreePath}`);
996
+ }
997
+ const gitdirCheck = verifyWorktreeGitdirPointer(worktreePath, commonGitDir);
998
+ if (!gitdirCheck.ok) {
999
+ throw new Error(
1000
+ `Refusing to reuse worktree at ${worktreePath}: gitdir pointer check failed (${gitdirCheck.reason}) — possible tampering. Remove the worktree manually or run cleanup with force=true.`,
1001
+ );
1002
+ }
1048
1003
  let currentBranch: string;
1049
1004
  try {
1050
1005
  currentBranch = git(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -1228,6 +1183,18 @@ export async function prepareTaskWorkspaceAsync(
1228
1183
  worktreeExists = false;
1229
1184
  }
1230
1185
  if (worktreeExists) {
1186
+ // MED-1 gitdir-hijack guard (async mirror): verify the `.git` pointer
1187
+ // against the trusted worktree registry BEFORE any cwd-inside git call.
1188
+ const commonGitDir = resolveTrustedGitDir(repoRoot);
1189
+ if (!commonGitDir) {
1190
+ throw new Error(`Cannot resolve the trusted git dir for ${repoRoot}; refusing to reuse worktree at ${worktreePath}`);
1191
+ }
1192
+ const gitdirCheck = verifyWorktreeGitdirPointer(worktreePath, commonGitDir);
1193
+ if (!gitdirCheck.ok) {
1194
+ throw new Error(
1195
+ `Refusing to reuse worktree at ${worktreePath}: gitdir pointer check failed (${gitdirCheck.reason}) — possible tampering. Remove the worktree manually or run cleanup with force=true.`,
1196
+ );
1197
+ }
1231
1198
  let currentBranch: string;
1232
1199
  try {
1233
1200
  currentBranch = await gitAsync(worktreePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
@@ -1344,7 +1311,32 @@ export async function prepareTaskWorkspaceAsync(
1344
1311
  }
1345
1312
  }
1346
1313
 
1314
+ /** R-A1 (MED-1 follow-up): fail-closed capture guard. `git diff` (and
1315
+ * `--stat`/`--numstat`) runs `filter.<x>.clean` when it hashes modified
1316
+ * content whose `.gitattributes` assigns a filter, so a worker planting
1317
+ * `.gitattributes filter=fx` plus a flipped `.git` pointer to a poisoned
1318
+ * gitdir config could execute the filter payload in the orchestrator context
1319
+ * (hardenGitArgs does NOT neutralize filter definitions — there is no
1320
+ * --no-filters for diff). The pointer guard therefore runs INSIDE every
1321
+ * capture function so every caller is covered; on mismatch NO git runs and
1322
+ * the fail-safe result (empty diff / zero stat) is returned. */
1323
+ function captureGuardOk(worktreePath: string, fn: string): boolean {
1324
+ const trust = worktreeTrustCheck(worktreePath);
1325
+ if (trust.kind === "worktree" && !trust.ok) {
1326
+ logInternalError(
1327
+ "worktree.capture.gitdirMismatch",
1328
+ new Error(`worktree corrupted — skipping ${fn} for ${worktreePath}: ${trust.reason}`),
1329
+ `worktreePath=${worktreePath}`,
1330
+ );
1331
+ return false;
1332
+ }
1333
+ return true;
1334
+ }
1335
+
1347
1336
  export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat {
1337
+ if (!captureGuardOk(worktreePath, "captureWorktreeDiffStat")) {
1338
+ return { filesChanged: 0, insertions: 0, deletions: 0, diffStat: "" };
1339
+ }
1348
1340
  try {
1349
1341
  const diffStat = git(worktreePath, ["diff", "--stat"]);
1350
1342
  const numstat = git(worktreePath, ["diff", "--numstat"]);
@@ -1364,6 +1356,9 @@ export function captureWorktreeDiffStat(worktreePath: string): WorktreeDiffStat
1364
1356
  }
1365
1357
 
1366
1358
  export async function captureWorktreeDiffStatAsync(worktreePath: string): Promise<WorktreeDiffStat> {
1359
+ if (!captureGuardOk(worktreePath, "captureWorktreeDiffStatAsync")) {
1360
+ return { filesChanged: 0, insertions: 0, deletions: 0, diffStat: "" };
1361
+ }
1367
1362
  try {
1368
1363
  const diffStat = await gitAsync(worktreePath, ["diff", "--stat"]);
1369
1364
  const numstat = await gitAsync(worktreePath, ["diff", "--numstat"]);
@@ -1383,6 +1378,9 @@ export async function captureWorktreeDiffStatAsync(worktreePath: string): Promis
1383
1378
  }
1384
1379
 
1385
1380
  export function captureWorktreeDiff(worktreePath: string): string {
1381
+ if (!captureGuardOk(worktreePath, "captureWorktreeDiff")) {
1382
+ return "";
1383
+ }
1386
1384
  try {
1387
1385
  return git(worktreePath, ["diff", "--stat"]) + "\n\n" + git(worktreePath, ["diff"]);
1388
1386
  } catch (error) {
@@ -1392,6 +1390,9 @@ export function captureWorktreeDiff(worktreePath: string): string {
1392
1390
  }
1393
1391
 
1394
1392
  export async function captureWorktreeDiffAsync(worktreePath: string): Promise<string> {
1393
+ if (!captureGuardOk(worktreePath, "captureWorktreeDiffAsync")) {
1394
+ return "";
1395
+ }
1395
1396
  try {
1396
1397
  const [stat, full] = await Promise.all([gitAsync(worktreePath, ["diff", "--stat"]), gitAsync(worktreePath, ["diff"])]);
1397
1398
  return stat + "\n\n" + full;
@@ -1524,27 +1525,50 @@ function captureAgentWorktreeRecovery(manifest: TeamRunManifest, worktreePath: s
1524
1525
  * worktree/branch leak never crashes a workflow.
1525
1526
  */
1526
1527
  export function cleanupAgentWorktree(manifest: TeamRunManifest, worktreePath: string, branch?: string): void {
1527
- // Capture diff as artifact (best-effort).
1528
+ // MED-1 gitdir-hijack guard: run git with cwd INSIDE the worktree only when its
1529
+ // `.git` pointer matches the trusted registry (see git-hardening.ts). On mismatch
1530
+ // skip the diff/status/recovery capture (log loudly) — removal below still
1531
+ // happens, but only via the trusted main-repo cwd.
1532
+ let gitdirOk = false;
1528
1533
  try {
1529
- const diff = captureWorktreeDiff(worktreePath);
1530
- if (diff.trim() && !diff.startsWith("Failed to capture worktree diff")) {
1531
- writeArtifact(manifest.artifactsRoot, {
1532
- kind: "diff",
1533
- relativePath: `wf/worktree-diff-${Date.now()}-${randomBytes(2).toString("hex")}.diff`,
1534
- content: diff,
1535
- producer: "dynamic-workflow",
1536
- });
1537
- }
1538
- } catch (error) {
1539
- logInternalError("worktree.agent-cleanup.diff", error, `worktreePath=${worktreePath}`);
1534
+ const agentRepoRoot = findGitRoot(manifest.cwd);
1535
+ const agentCommonGitDir = resolveTrustedGitDir(agentRepoRoot);
1536
+ gitdirOk = agentCommonGitDir !== undefined && verifyWorktreeGitdirPointer(worktreePath, agentCommonGitDir).ok;
1537
+ } catch {
1538
+ gitdirOk = false;
1540
1539
  }
1541
- // R36-M1: `git diff` above cannot see untracked files — capture them too, or
1542
- // the force-remove below destroys the agent's new files with NO artifact.
1543
- try {
1544
- const dirtyStatus = git(worktreePath, ["-c", "core.quotePath=false", "status", "--porcelain", "-uall"]);
1545
- if (/^\?\? /m.test(dirtyStatus)) captureAgentWorktreeRecovery(manifest, worktreePath, dirtyStatus);
1546
- } catch (error) {
1547
- logInternalError("worktree.agent-cleanup.status", error, `worktreePath=${worktreePath}`);
1540
+ if (!gitdirOk) {
1541
+ logInternalError(
1542
+ "worktree.agent-cleanup.gitdirMismatch",
1543
+ new Error(
1544
+ `gitdir pointer check failed for ${worktreePath} — skipping diff/recovery capture; removing via main-repo git only (MED-1)`,
1545
+ ),
1546
+ `runId=${manifest.runId}`,
1547
+ );
1548
+ }
1549
+ if (gitdirOk) {
1550
+ // Capture diff as artifact (best-effort).
1551
+ try {
1552
+ const diff = captureWorktreeDiff(worktreePath);
1553
+ if (diff.trim() && !diff.startsWith("Failed to capture worktree diff")) {
1554
+ writeArtifact(manifest.artifactsRoot, {
1555
+ kind: "diff",
1556
+ relativePath: `wf/worktree-diff-${Date.now()}-${randomBytes(2).toString("hex")}.diff`,
1557
+ content: diff,
1558
+ producer: "dynamic-workflow",
1559
+ });
1560
+ }
1561
+ } catch (error) {
1562
+ logInternalError("worktree.agent-cleanup.diff", error, `worktreePath=${worktreePath}`);
1563
+ }
1564
+ // R36-M1: `git diff` above cannot see untracked files — capture them too, or
1565
+ // the force-remove below destroys the agent's new files with NO artifact.
1566
+ try {
1567
+ const dirtyStatus = git(worktreePath, ["-c", "core.quotePath=false", "status", "--porcelain", "-uall"]);
1568
+ if (/^\?\? /m.test(dirtyStatus)) captureAgentWorktreeRecovery(manifest, worktreePath, dirtyStatus);
1569
+ } catch (error) {
1570
+ logInternalError("worktree.agent-cleanup.status", error, `worktreePath=${worktreePath}`);
1571
+ }
1548
1572
  }
1549
1573
  // Remove worktree (best-effort). Try git first, then fall back to fs.rm.
1550
1574
  try {
@@ -1579,26 +1603,49 @@ export function cleanupAgentWorktree(manifest: TeamRunManifest, worktreePath: st
1579
1603
 
1580
1604
  /** Async version of cleanupAgentWorktree — caches findGitRoot and yields during git operations. */
1581
1605
  export async function cleanupAgentWorktreeAsync(manifest: TeamRunManifest, worktreePath: string, branch?: string): Promise<void> {
1582
- // Capture diff as artifact (best-effort).
1606
+ // MED-1 gitdir-hijack guard: run git with cwd INSIDE the worktree only when its
1607
+ // `.git` pointer matches the trusted registry (see git-hardening.ts). On mismatch
1608
+ // skip the diff/status/recovery capture (log loudly) — removal below still
1609
+ // happens, but only via the trusted main-repo cwd.
1610
+ let gitdirOk = false;
1583
1611
  try {
1584
- const diff = await captureWorktreeDiffAsync(worktreePath);
1585
- if (diff.trim() && !diff.startsWith("Failed to capture worktree diff")) {
1586
- writeArtifact(manifest.artifactsRoot, {
1587
- kind: "diff",
1588
- relativePath: `wf/worktree-diff-${Date.now()}-${randomBytes(2).toString("hex")}.diff`,
1589
- content: diff,
1590
- producer: "dynamic-workflow",
1591
- });
1592
- }
1593
- } catch (error) {
1594
- logInternalError("worktree.agent-cleanup.diff", error, `worktreePath=${worktreePath}`);
1612
+ const agentRepoRoot = await findGitRootAsync(manifest.cwd);
1613
+ const agentCommonGitDir = resolveTrustedGitDir(agentRepoRoot);
1614
+ gitdirOk = agentCommonGitDir !== undefined && verifyWorktreeGitdirPointer(worktreePath, agentCommonGitDir).ok;
1615
+ } catch {
1616
+ gitdirOk = false;
1595
1617
  }
1596
- // R36-M1: async mirror — untracked files are invisible to `git diff`.
1597
- try {
1598
- const dirtyStatus = await gitAsync(worktreePath, ["-c", "core.quotePath=false", "status", "--porcelain", "-uall"]);
1599
- if (/^\?\? /m.test(dirtyStatus)) captureAgentWorktreeRecovery(manifest, worktreePath, dirtyStatus);
1600
- } catch (error) {
1601
- logInternalError("worktree.agent-cleanup.status", error, `worktreePath=${worktreePath}`);
1618
+ if (!gitdirOk) {
1619
+ logInternalError(
1620
+ "worktree.agent-cleanup.gitdirMismatch",
1621
+ new Error(
1622
+ `gitdir pointer check failed for ${worktreePath} — skipping diff/recovery capture; removing via main-repo git only (MED-1)`,
1623
+ ),
1624
+ `runId=${manifest.runId}`,
1625
+ );
1626
+ }
1627
+ if (gitdirOk) {
1628
+ // Capture diff as artifact (best-effort).
1629
+ try {
1630
+ const diff = await captureWorktreeDiffAsync(worktreePath);
1631
+ if (diff.trim() && !diff.startsWith("Failed to capture worktree diff")) {
1632
+ writeArtifact(manifest.artifactsRoot, {
1633
+ kind: "diff",
1634
+ relativePath: `wf/worktree-diff-${Date.now()}-${randomBytes(2).toString("hex")}.diff`,
1635
+ content: diff,
1636
+ producer: "dynamic-workflow",
1637
+ });
1638
+ }
1639
+ } catch (error) {
1640
+ logInternalError("worktree.agent-cleanup.diff", error, `worktreePath=${worktreePath}`);
1641
+ }
1642
+ // R36-M1: async mirror — untracked files are invisible to `git diff`.
1643
+ try {
1644
+ const dirtyStatus = await gitAsync(worktreePath, ["-c", "core.quotePath=false", "status", "--porcelain", "-uall"]);
1645
+ if (/^\?\? /m.test(dirtyStatus)) captureAgentWorktreeRecovery(manifest, worktreePath, dirtyStatus);
1646
+ } catch (error) {
1647
+ logInternalError("worktree.agent-cleanup.status", error, `worktreePath=${worktreePath}`);
1648
+ }
1602
1649
  }
1603
1650
  // Resolve repoRoot once and reuse (cache optimization).
1604
1651
  let repoRoot: string | undefined;