pi-crew 0.11.43 → 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.
@@ -30,6 +30,8 @@ import type {
30
30
  UsageState,
31
31
  VerificationEvidence,
32
32
  } from "../../state/types.ts";
33
+ import { logInternalError } from "../../utils/internal-error.ts";
34
+ import { buildGitSafeEnv, hardenGitArgs, worktreeTrustCheck } from "../../worktree/git-hardening.ts";
33
35
  import { captureWorktreeDiffAsync, captureWorktreeDiffStatAsync } from "../../worktree/worktree-manager.ts";
34
36
  import { appendTaskAttentionEvent } from "../attention-events.ts";
35
37
  import { extractCommandTrace } from "../command-trace.ts";
@@ -150,16 +152,48 @@ const DRIFT_BLOCK_MARKER = /DRIFT-BLOCK\b/i;
150
152
  * READ-ONLY lanes — any entry (modified OR untracked) is an unexpected
151
153
  * mutation. Returns undefined when the tree is clean or git fails (fail-open:
152
154
  * a broken probe must not break task finalization). Note-only, never a fail.
155
+ * R-A2 exception: a FAILED GITDIR-POINTER GUARD is not a broken probe but a
156
+ * live corruption signal — it reports a mutation note (see body).
153
157
  */
154
158
  export async function detectReadLaneMutation(worktreePath: string): Promise<NonNullable<TeamTaskState["unexpectedMutation"]> | undefined> {
155
159
  try {
160
+ // R-A2 (MED-1 follow-up): this probe used to be a raw execFile with the
161
+ // FULL process.env — API keys could leak to worker-planted fsmonitor /
162
+ // hook config reachable from the probed worktree. It now routes through
163
+ // the MED-1 hardening: hardenGitArgs config-injection overrides, the
164
+ // sanitized git env (no process.env inheritance), and the gitdir-pointer
165
+ // guard. Guard failure is fail-safe TOWARD REPORTING a mutation: a
166
+ // corrupted `.git` pointer in a READ-ONLY lane is itself a lane
167
+ // violation (the worker rewrote something it must not touch), so it
168
+ // surfaces as a note + internal log instead of a clean verdict.
169
+ const trust = worktreeTrustCheck(worktreePath);
170
+ if (trust.kind === "worktree" && !trust.ok) {
171
+ logInternalError(
172
+ "post-execution.readLane.gitdirMismatch",
173
+ new Error(`Refusing to run the mutation probe inside ${worktreePath}: ${trust.reason}`),
174
+ `worktreePath=${worktreePath}`,
175
+ );
176
+ return {
177
+ filesChanged: 0,
178
+ sample: [`worktree corrupted — gitdir pointer check failed (${trust.reason}); no git executed`],
179
+ };
180
+ }
156
181
  // W10-8 one-shot git probe per read-only task — child_process is
157
182
  // already in the platform; dynamic keeps it off the static import graph.
158
183
  // LAZY: see above
159
184
  const { execFile } = await import("node:child_process");
160
185
  const out: string = await new Promise((resolve, reject) => {
161
- execFile("git", ["status", "--porcelain"], { cwd: worktreePath, encoding: "utf-8", timeout: 10_000 }, (err, stdout) =>
162
- err ? reject(err) : resolve(stdout),
186
+ execFile(
187
+ "git",
188
+ hardenGitArgs(["status", "--porcelain"]),
189
+ {
190
+ cwd: worktreePath,
191
+ env: buildGitSafeEnv(),
192
+ encoding: "utf-8",
193
+ timeout: 10_000,
194
+ windowsHide: true,
195
+ },
196
+ (err, stdout) => (err ? reject(err) : resolve(stdout)),
163
197
  );
164
198
  });
165
199
  const entries = out
@@ -60,7 +60,7 @@ import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
60
60
  import type { LiveAgentHandle } from "../runtime/live-session/live-agent-manager.ts";
61
61
  import type { ManifestCache } from "../runtime/manifest-cache.ts";
62
62
  import { createManifestCache } from "../runtime/manifest-cache.ts";
63
- import { hasFreshTaskHeartbeatEvidence, isActiveRunStatus, isDisplayActiveRun } from "../runtime/process-status.ts";
63
+ import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../runtime/process-status.ts";
64
64
  import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
65
65
  import { surfaceGateEnvSnapshot } from "../runtime/surface/resolve-surface.ts";
66
66
  import { getTaskUsage } from "../runtime/usage-tracker.ts";
@@ -504,8 +504,7 @@ export class AgentsJobsBrowser {
504
504
  // heartbeats are fresh (this loop already does per-run I/O — one more
505
505
  // coalesced read is in-pattern). Terminal/async runs are never
506
506
  // resurrected (isActiveRunStatus && !run.async).
507
- const heartbeatLive =
508
- isActiveRunStatus(run.status) && !run.async && hasFreshTaskHeartbeatEvidence(readLegacyRunTasks(run), nowMs);
507
+ const heartbeatLive = heartbeatLiveForDisplay(run, readLegacyRunTasks(run), nowMs);
509
508
  if (isDisplayActiveRun(run, agents, nowMs) || heartbeatLive) runs.push({ run, agents });
510
509
  } catch {
511
510
  /* unreadable — skip */
@@ -5,7 +5,7 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
5
5
  import { listLiveAgents, listLiveAgentsByWorkspace } from "../runtime/live-session/live-agent-manager.ts";
6
6
  import type { ManifestCache } from "../runtime/manifest-cache.ts";
7
7
  import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
8
- import { isDisplayActiveRun } from "../runtime/process-status.ts";
8
+ import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../runtime/process-status.ts";
9
9
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
10
10
  import { aggregateUsage } from "../state/usage.ts";
11
11
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
@@ -307,7 +307,11 @@ class PowerbarPublisher {
307
307
  }
308
308
  return { run, agents, tasks: readTasks(run.tasksPath), snapshot };
309
309
  })
310
- .filter((item) => isDisplayActiveRun(item.run, item.agents));
310
+ // Wave 16 (residual render wiring): item.tasks is already in scope
311
+ // (snapshot.tasks | readTasks fallback) — OR-ing the heartbeat guard in
312
+ // adds ZERO extra disk reads; a frozen-manifest run stays in the
313
+ // powerbar while its task heartbeats stay fresh.
314
+ .filter((item) => isDisplayActiveRun(item.run, item.agents) || heartbeatLiveForDisplay(item.run, item.tasks));
311
315
  if (!active.length) {
312
316
  this.#lastActiveKey = undefined;
313
317
  this.#lastProgressKey = undefined;
@@ -10,12 +10,7 @@ import { readCrewAgents } from "../runtime/crew-agent-records.ts";
10
10
  import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
11
11
  import { getLiveAgentContextPercent } from "../runtime/live-session/live-agent-manager.ts";
12
12
  import { isPlanApprovalPending } from "../runtime/plan-approval.ts";
13
- import {
14
- hasFreshTaskHeartbeatEvidence,
15
- isActiveRunStatus,
16
- isDisplayActiveRun,
17
- isLikelyOrphanedActiveRun,
18
- } from "../runtime/process-status.ts";
13
+ import { heartbeatLiveForDisplay, isActiveRunStatus, isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
19
14
  import type { ScheduledJob } from "../runtime/scheduling/scheduler.ts";
20
15
  import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
21
16
  import { aggregateUsage } from "../state/usage.ts";
@@ -368,19 +363,6 @@ function taskForAgent(tasks: TeamTaskState[], agent: CrewAgentRecord): TeamTaskS
368
363
  return tasks.find((task) => task.id === agent.taskId);
369
364
  }
370
365
 
371
- /**
372
- * Wave 15 (F2, GAP-0): tight heartbeat-liveness guard for ACTIVE display —
373
- * mirrors the orphan predicate's own preconditions (active status, non-async)
374
- * so a terminal or async run is never resurrected by a heartbeat. A healthy
375
- * direct run mid-long-tool freezes manifest `updatedAt` and agent progress
376
- * (event-driven writes) but keeps stamping `task.heartbeat.lastSeenAt`, so
377
- * fresh evidence means the run is alive. Call sites OR this in ONLY — every
378
- * non-stale branch and layout stays untouched.
379
- */
380
- function heartbeatLiveForDisplay(run: TeamRunManifest, tasks: TeamTaskState[] | undefined, now = Date.now()): boolean {
381
- return isActiveRunStatus(run.status) && !run.async && hasFreshTaskHeartbeatEvidence(tasks, now);
382
- }
383
-
384
366
  function modelForTask(task: TeamTaskState | undefined): string | undefined {
385
367
  const attempts = task?.modelAttempts;
386
368
  if (!attempts?.length) return undefined;
@@ -9,7 +9,7 @@ import { readCrewAgents } from "../../runtime/crew-agent-records.ts";
9
9
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
10
10
  import { evictStaleLiveAgentHandles } from "../../runtime/live-session/live-agent-manager.ts";
11
11
  import type { ManifestCache } from "../../runtime/manifest-cache.ts";
12
- import { isDisplayActiveRun } from "../../runtime/process-status.ts";
12
+ import { heartbeatLiveForDisplay, isDisplayActiveRun } from "../../runtime/process-status.ts";
13
13
  import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts";
14
14
  import type { TeamRunManifest } from "../../state/types.ts";
15
15
  import { goalFlagSuffix } from "../goal-flag.ts";
@@ -61,42 +61,55 @@ export function activeWidgetRuns(
61
61
  // workspaceId stays in scope: crash recovery below is still session-scoped.
62
62
  const runs = preloadedManifests ?? (manifestCache ? manifestCache.list(20) : listRecentRuns(cwd, 20));
63
63
 
64
- return runs
65
- .map((run) => {
66
- try {
67
- // U7 (spec 2026-10-09): prefer the committed-state read when the
68
- // cache exposes it. `readForRender` returns the SAME cached
69
- // snapshot `get` would (identical render — parity), but a lapsed
70
- // TTL schedules the coalesced ASYNC refresh (80ms batch → stamp
71
- // check → rebuild only when files changed) instead of waiting for
72
- // an external poller to notice. Structural check: legacy callers
73
- // and test doubles may implement only `get`.
74
- const committed = snapshotCache as
75
- | { readForRender?: (runId: string) => RunUiSnapshot | undefined; get?: (runId: string) => RunUiSnapshot | undefined }
76
- | undefined;
77
- const snapshot =
78
- typeof committed?.readForRender === "function" ? committed.readForRender(run.runId) : snapshotCache?.get(run.runId);
79
- if (snapshot) {
80
- return {
81
- run: snapshot.manifest,
82
- agents: snapshot.agents,
83
- snapshot,
84
- };
64
+ return (
65
+ runs
66
+ .map((run) => {
67
+ try {
68
+ // U7 (spec 2026-10-09): prefer the committed-state read when the
69
+ // cache exposes it. `readForRender` returns the SAME cached
70
+ // snapshot `get` would (identical render — parity), but a lapsed
71
+ // TTL schedules the coalesced ASYNC refresh (80ms batch → stamp
72
+ // check → rebuild only when files changed) instead of waiting for
73
+ // an external poller to notice. Structural check: legacy callers
74
+ // and test doubles may implement only `get`.
75
+ const committed = snapshotCache as
76
+ | {
77
+ readForRender?: (runId: string) => RunUiSnapshot | undefined;
78
+ get?: (runId: string) => RunUiSnapshot | undefined;
79
+ }
80
+ | undefined;
81
+ const snapshot =
82
+ typeof committed?.readForRender === "function" ? committed.readForRender(run.runId) : snapshotCache?.get(run.runId);
83
+ if (snapshot) {
84
+ return {
85
+ run: snapshot.manifest,
86
+ agents: snapshot.agents,
87
+ snapshot,
88
+ };
89
+ }
90
+ // P0-6: render from snapshots only — never read disk on every
91
+ // render tick. When the snapshot cache is provided but hasn't
92
+ // populated yet (first frame after `updateCrewWidget`), drop the
93
+ // run from the result so the widget paints "(loading…)" instead
94
+ // of calling `agentsFor(run) → readCrewAgents`. Legacy callers
95
+ // (no snapshotCache) keep the disk-read fallback for tests/dev.
96
+ if (snapshotCache) return null;
97
+ return { run, agents: agentsFor(run) };
98
+ } catch {
99
+ if (snapshotCache) return null;
100
+ return { run, agents: agentsFor(run) };
85
101
  }
86
- // P0-6: render from snapshots only — never read disk on every
87
- // render tick. When the snapshot cache is provided but hasn't
88
- // populated yet (first frame after `updateCrewWidget`), drop the
89
- // run from the result so the widget paints "(loading…)" instead
90
- // of calling `agentsFor(run) → readCrewAgents`. Legacy callers
91
- // (no snapshotCache) keep the disk-read fallback for tests/dev.
92
- if (snapshotCache) return null;
93
- return { run, agents: agentsFor(run) };
94
- } catch {
95
- if (snapshotCache) return null;
96
- return { run, agents: agentsFor(run) };
97
- }
98
- })
99
- .filter((item): item is WidgetRun => item !== null && isDisplayActiveRun(item.run, item.agents));
102
+ })
103
+ // Wave 16 (residual render wiring): the dock's high-frequency tick NEVER
104
+ // gains a disk read here — only the snapshot the render path already
105
+ // holds. OR-in ONLY: a run hidden because its manifest froze mid-long-tool
106
+ // stays visible while its task heartbeats are fresh (GAP-4 mix-A).
107
+ .filter(
108
+ (item): item is WidgetRun =>
109
+ item !== null &&
110
+ (isDisplayActiveRun(item.run, item.agents, now) || heartbeatLiveForDisplay(item.run, item.snapshot?.tasks, now)),
111
+ )
112
+ );
100
113
  }
101
114
 
102
115
  /**
@@ -122,11 +122,62 @@ export async function prepareBrokerSocketDir(sockPath: string): Promise<void> {
122
122
  }
123
123
  }
124
124
 
125
+ /** Literal-escaping helper for building filesystem-name regexes. */
126
+ function escapeRegExp(literal: string): string {
127
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
128
+ }
129
+
130
+ /**
131
+ * B-LOW1: sweep `${sockPath}.stopping.<pid>.<hex>` shield corpses left by
132
+ * broker/socket-stop.ts. stopServerAndRemoveSocket renames a foreign/unpinned
133
+ * socket leaf aside across the libuv close-unlink window and renames it back
134
+ * afterwards; a crash between those renames strands the shield forever — the
135
+ * plain removeStaleBrokerSocket flow only ever looks at `sockPath` itself, so
136
+ * the corpses previously accumulated one per crashed stop.
137
+ *
138
+ * A corpse is removed ONLY when the pid embedded in its name is provably dead
139
+ * (process.kill(pid, 0) → ESRCH). EPERM/any other signal result means the pid
140
+ * is alive (or its liveness cannot be proven) — the shield is kept (fail-safe:
141
+ * a live broker may still restore it). Best-effort: never throws.
142
+ */
143
+ async function sweepStoppingShields(sockPath: string): Promise<void> {
144
+ if (process.platform === "win32") return; // named pipes have no fs leaves
145
+ const dir = path.dirname(sockPath);
146
+ const pattern = new RegExp(`^${escapeRegExp(path.basename(sockPath))}\\.stopping\\.(\\d+)\\.[0-9a-f]{1,32}$`);
147
+ let entries: string[];
148
+ try {
149
+ entries = await fsp.readdir(dir);
150
+ } catch {
151
+ return;
152
+ }
153
+ for (const entry of entries) {
154
+ const match = pattern.exec(entry);
155
+ if (!match) continue;
156
+ const pid = Number.parseInt(match[1]!, 10);
157
+ if (!Number.isInteger(pid) || pid <= 0) continue;
158
+ try {
159
+ process.kill(pid, 0);
160
+ continue; // pid alive — keep the shield
161
+ } catch (err) {
162
+ const code = (err as NodeJS.ErrnoException).code;
163
+ if (code !== "ESRCH") continue; // cannot prove dead — keep
164
+ }
165
+ try {
166
+ await fsp.unlink(path.join(dir, entry));
167
+ } catch {
168
+ /* best-effort */
169
+ }
170
+ }
171
+ }
172
+
125
173
  /** Connect-then-unlink stale socket (herdr pattern). If a live broker is
126
174
  * listening, leave the endpoint intact (EADDRINUSE will surface on bind).
127
175
  * If a stale file exists with no listener, remove it. If the path is a
128
176
  * symlink, refuse rather than follow.
129
177
  *
178
+ * Also sweeps `${sockPath}.stopping.*` shield corpses whose recorded pid is
179
+ * dead (B-LOW1 — see sweepStoppingShields).
180
+ *
130
181
  * Returns "removed" when the stale entry was unlinked, "kept" when a live
131
182
  * listener was detected, "absent" when no entry existed, "refused"
132
183
  * when the entry is a symlink. */
@@ -138,6 +189,9 @@ export async function removeStaleBrokerSocket(
138
189
  // the timeout branch without racing a real localhost connect.
139
190
  netModule?: { createConnection: (path: string) => { once: (ev: string, fn: (arg?: unknown) => void) => void; destroy: () => void } },
140
191
  ): Promise<"removed" | "kept" | "absent" | "refused"> {
192
+ // B-LOW1: sweep stranded stop-shields first (best-effort, never affects the
193
+ // sockPath verdict below).
194
+ await sweepStoppingShields(sockPath);
141
195
  // Reject symlinks outright.
142
196
  let st: Awaited<ReturnType<typeof fsp.lstat>>;
143
197
  try {
@@ -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);