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.
@@ -0,0 +1,507 @@
1
+ /**
2
+ * MED-1 (git config/hook injection hardening) — shared helpers.
3
+ *
4
+ * Threat model: a worker with WRITE access to a worktree controls every file
5
+ * INSIDE the worktree, including the `.git` pointer file of a linked worktree.
6
+ * Unhardened git invocations with `cwd=<worktree>` therefore let that worker
7
+ * execute code in the ORCHESTRATOR process context through:
8
+ *
9
+ * - `core.fsmonitor` (config) — spawned on status/add/commit/diff
10
+ * - `core.hooksPath` (config) — pre/post-commit, prepare-commit-msg…
11
+ * - `diff.external` / `diff.<drv>.textconv` (config) — spawned by `git diff`
12
+ * - `filter.<x>.clean/smudge` (config, driven by in-tree `.gitattributes`) —
13
+ * spawned by add/status/diff/checkout
14
+ *
15
+ * The config values above can only come from a config file the worker does NOT
16
+ * control (main-repo `.git/config`, global/system config) OR from a hijacked
17
+ * gitdir: pointing the worktree `.git` file at an attacker-built directory that
18
+ * contains a poisoned `config`. The helpers here cover both layers:
19
+ *
20
+ * 1. `hardenGitArgs()` — `-c` overrides that neutralize fsmonitor/hooksPath/
21
+ * attributesFile plus `--no-textconv --no-ext-diff` (diff) and `--no-verify`
22
+ * (commit). Empirically verified (git 2.43): `-c core.hooksPath=` (empty)
23
+ * disables ALL hook execution (including default hooks) without breaking
24
+ * `git commit`; `--no-verify` alone does NOT cover prepare-commit-msg /
25
+ * post-commit, which is why the hooksPath override is required.
26
+ * 2. `verifyWorktreeGitdirPointer()` — fail-closed gitdir-hijack guard: before
27
+ * ANY git runs with `cwd=<worktree>`, the worktree `.git` pointer must
28
+ * resolve to the git-REGISTERED admin dir (source of truth:
29
+ * `<commonGitDir>/worktrees/<name>/gitdir`, written by git itself and stored in
30
+ * the trusted main repo). NOTE: admin dir names are NOT derivable from the
31
+ * worktree basename — git uniquifies collisions (`task9` → `task91`).
32
+ * 3. `verifyForeignWorktreeLinkage()` (R-A3b) — fallback for linked worktrees
33
+ * with NO trusted `.git` anchor above them (foreign sibling shape: main repo
34
+ * and worktree live as peers, e.g. test fixtures under os.tmpdir()). Verifies
35
+ * the two-way pointer/backlink pair; see the SECURITY NOTE on the helper.
36
+ *
37
+ * KNOWN RESIDUAL (documented): a `filter.<x>.clean` DEFINITION in a genuinely
38
+ * trusted config cannot be neutralized (`git add` has no --no-filters and
39
+ * legit filters must keep working), but the worker-reachable path to a poisoned
40
+ * filter definition is exactly the gitdir hijack this module blocks.
41
+ */
42
+
43
+ import * as fs from "node:fs";
44
+ import * as path from "node:path";
45
+ import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
46
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
47
+ import { logInternalError } from "../utils/internal-error.ts";
48
+
49
+ /**
50
+ * `-c` overrides prepended to every hardened git invocation.
51
+ * - core.fsmonitor=false — never spawn an fsmonitor helper
52
+ * - core.hooksPath= — empty value disables ALL hooks (verified: default
53
+ * hooks are skipped too, commit still succeeds)
54
+ * - core.attributesFile= — ignore the config-pointed extra attributes file
55
+ * (in-tree .gitattributes still applies; attributes
56
+ * alone cannot execute anything without a config-side
57
+ * filter/diff definition)
58
+ */
59
+ export const GIT_HARDEN_CONFIG_ARGS = ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=", "-c", "core.attributesFile="];
60
+
61
+ /** Global git options that consume the NEXT argv token as their value
62
+ * (R-A8: `-C <dir>` / `--git-dir <dir>` etc. — the value was previously
63
+ * mistaken for the subcommand, so `diff`/`commit` hardening flags were
64
+ * silently dropped for any argv carrying one of these). */
65
+ const GLOBAL_OPTIONS_WITH_SEPARATE_VALUE = new Set([
66
+ "-c",
67
+ "-C",
68
+ "--config-env",
69
+ "--exec-path",
70
+ "--git-dir",
71
+ "--namespace",
72
+ "--super-prefix",
73
+ "--work-tree",
74
+ ]);
75
+
76
+ /** First positional (subcommand) of a git argv, skipping global options. */
77
+ function firstSubcommand(args: readonly string[]): string | undefined {
78
+ for (let i = 0; i < args.length; i++) {
79
+ const arg = args[i];
80
+ if (arg === undefined) break;
81
+ if (GLOBAL_OPTIONS_WITH_SEPARATE_VALUE.has(arg)) {
82
+ i++; // this option consumes the NEXT token as its value
83
+ continue;
84
+ }
85
+ if (arg.startsWith("-")) continue; // --work-tree=…, --git-dir=… (inline value forms)
86
+ return arg;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /**
92
+ * Build the hardened argv for a git command (MED-1).
93
+ * - always: GIT_HARDEN_CONFIG_ARGS prefix
94
+ * - `diff` → append --no-textconv --no-ext-diff (kills textconv + diff.external)
95
+ * - `commit`→ append --no-verify (skips pre-commit/commit-msg; hooksPath= above
96
+ * additionally covers prepare-commit-msg/post-commit)
97
+ */
98
+ export function hardenGitArgs(args: string[]): string[] {
99
+ const hardened = [...GIT_HARDEN_CONFIG_ARGS, ...args];
100
+ const sub = firstSubcommand(args);
101
+ if (sub === "diff") {
102
+ hardened.push("--no-textconv", "--no-ext-diff");
103
+ } else if (sub === "commit") {
104
+ hardened.push("--no-verify");
105
+ }
106
+ return hardened;
107
+ }
108
+
109
+ /** realpath with graceful fallback (native first for Windows long-name form). */
110
+ function realPathBestEffort(p: string): string {
111
+ try {
112
+ const r = fs.realpathSync.native(p);
113
+ return r.startsWith("\\\\?\\") ? r.slice(4) : r;
114
+ } catch {
115
+ try {
116
+ return fs.realpathSync(p);
117
+ } catch {
118
+ return p;
119
+ }
120
+ }
121
+ }
122
+
123
+ /** Path equality that is case-insensitive + separator-normalized on Windows. */
124
+ function sameGitPath(a: string, b: string): boolean {
125
+ const norm = (p: string): string => {
126
+ const n = p.replace(/\\/g, "/");
127
+ return process.platform === "win32" ? n.toLowerCase() : n;
128
+ };
129
+ return norm(a) === norm(b);
130
+ }
131
+
132
+ /**
133
+ * Resolve the TRUSTED git (common) dir for a repository path by walking up the
134
+ * directory tree from `startPath` — the same discovery git performs, but using
135
+ * only trusted directories (a worktree `.git` pointer is never consulted from
136
+ * an untrusted directory because `startPath` is either the trusted main-repo
137
+ * cwd/repoRoot (strict path) or a shape-validated `<main>` root derived from
138
+ * the pointer target in the foreign-worktree fallback — never the worktree
139
+ * itself).
140
+ *
141
+ * Handles: `.git` directory (normal repo), `.git` file (linked worktree or
142
+ * submodule → parse `gitdir:`), and follows `commondir` so a repo root that is
143
+ * itself a linked worktree still yields the shared common dir where `config`
144
+ * and the `worktrees/` registry live.
145
+ *
146
+ * Returns undefined when no `.git` is found on the walk-up.
147
+ */
148
+ export function resolveTrustedGitDir(startPath: string): string | undefined {
149
+ let current = path.resolve(startPath);
150
+ // eslint-disable-next-line no-constant-condition
151
+ while (true) {
152
+ const dotGit = path.join(current, ".git");
153
+ let gitDir: string | undefined;
154
+ try {
155
+ const st = fs.lstatSync(dotGit);
156
+ if (st.isDirectory()) {
157
+ gitDir = dotGit;
158
+ } else if (st.isFile()) {
159
+ const match = fs.readFileSync(dotGit, "utf-8").match(/^gitdir:\s*(.+)$/m);
160
+ if (match) {
161
+ const target = match[1]!.trim();
162
+ gitDir = path.isAbsolute(target) ? target : path.resolve(current, target);
163
+ }
164
+ }
165
+ // symlinks and anything else are not trusted anchors
166
+ } catch {
167
+ /* no .git at this level — keep walking up */
168
+ }
169
+ if (gitDir !== undefined && fs.existsSync(gitDir)) {
170
+ // Linked-worktree git dirs keep config + worktrees registry in the
171
+ // COMMON dir (git's commondir file, usually a relative "../..").
172
+ const commonFile = path.join(gitDir, "commondir");
173
+ try {
174
+ if (fs.statSync(commonFile).isFile()) {
175
+ const raw = fs.readFileSync(commonFile, "utf-8").trim();
176
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(gitDir, raw);
177
+ if (fs.existsSync(abs)) return realPathBestEffort(abs);
178
+ }
179
+ } catch {
180
+ /* fall through to gitDir itself */
181
+ }
182
+ return realPathBestEffort(gitDir);
183
+ }
184
+ const parent = path.dirname(current);
185
+ if (parent === current) return undefined;
186
+ current = parent;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Shared sanitized env for EVERY git invocation (MED-1 / R-A4 / R-A6).
192
+ *
193
+ * Replaces the three drifting per-module copies (cleanup.ts GIT_SAFE_ENV,
194
+ * worktree-manager.ts gitEnv(), branch-freshness.ts GIT_SAFE_ENV):
195
+ * - allowList-based `sanitizeEnvSecrets` — the child git process (and any
196
+ * config-reachable helper it spawns) never sees API keys/tokens;
197
+ * - `GIT_CONFIG_NOSYSTEM=1` — the system-wide gitconfig is never read, which
198
+ * ALSO makes a forwarded `GIT_CONFIG_SYSTEM` env var a no-op on git>=2.32
199
+ * (R-A4a: `GIT_CONFIG_SYSTEM` is therefore REMOVED from the allowlist —
200
+ * forwarding it would re-open exactly the surface NOSYSTEM closes);
201
+ * - `GIT_CONFIG_GLOBAL` is KEPT (R-A4b): `git commit` needs user.name/email
202
+ * from the user's global config — removing it breaks commits for users
203
+ * without repo-local identity. KNOWN RESIDUAL (accepted, documented): a
204
+ * `filter.<x>.clean/smudge` or `diff.external` DEFINITION living in a
205
+ * genuinely trusted global config is executable and cannot be neutralized
206
+ * without breaking legitimate filters — the worker-reachable path to a
207
+ * POISONED definition (gitdir hijack) is what the pointer guards block.
208
+ */
209
+ export function buildGitSafeEnv(locale: "C" | "en_US.UTF-8" = "C"): Record<string, string> {
210
+ const allowList = [
211
+ "PATH",
212
+ "HOME",
213
+ "USER",
214
+ ...WINDOWS_ESSENTIAL_ENV_VARS,
215
+ "SHELL",
216
+ "TERM",
217
+ "LANG",
218
+ "LC_ALL",
219
+ "LC_COLLATE",
220
+ "LC_CTYPE",
221
+ "LC_MESSAGES",
222
+ "XDG_CONFIG_HOME",
223
+ "XDG_DATA_HOME",
224
+ "XDG_CACHE_HOME",
225
+ "NVM_BIN",
226
+ "NVM_DIR",
227
+ "NODE_PATH",
228
+ "GIT_CONFIG_GLOBAL",
229
+ "GIT_EXEC_PATH",
230
+ "GIT_AUTHOR_NAME",
231
+ "GIT_AUTHOR_EMAIL",
232
+ "GIT_COMMITTER_NAME",
233
+ "GIT_COMMITTER_EMAIL",
234
+ ];
235
+ // R-A4 win32 fix (CI run 40467894728): process.env enumeration is
236
+ // CASE-PRESERVING on Windows (GitHub runners carry `Path`, `SystemRoot`,
237
+ // `ComSpec`), while the allowlist above is exact-match — so PATH silently
238
+ // vanished from git's env on win32. Re-key each enumerated entry to the
239
+ // allowlist's canonical casing before filtering (keys not in the allowlist
240
+ // are dropped by the filter either way). Neutral everywhere else: POSIX
241
+ // names of interest are already uppercase, and win32 env lookup is
242
+ // case-insensitive, so the child git process is unaffected by re-casing.
243
+ const canonicalCase = new Map<string, string>();
244
+ for (const entry of allowList) canonicalCase.set(entry.toUpperCase(), entry);
245
+ const normalizedEnv: Record<string, string> = {};
246
+ for (const [key, value] of Object.entries(process.env)) {
247
+ if (value === undefined) continue;
248
+ normalizedEnv[canonicalCase.get(key.toUpperCase()) ?? key] = value;
249
+ }
250
+ return {
251
+ ...sanitizeEnvSecrets(normalizedEnv, { allowList }),
252
+ LANG: locale,
253
+ LC_ALL: locale,
254
+ // MED-1: never read the system-wide gitconfig for worktree operations —
255
+ // shrinks the config surface a poisoned environment could reach. Set
256
+ // UNCONDITIONALLY on every platform (git for Windows honours it too).
257
+ GIT_CONFIG_NOSYSTEM: "1",
258
+ };
259
+ }
260
+
261
+ /** Result of the R-A3 per-command trust check (see {@link worktreeTrustCheck}). */
262
+ export type WorktreeTrust =
263
+ /** Nearest `.git` anchor walking up from cwd is a DIRECTORY (a main-repo
264
+ * context) — trusted, nothing to re-verify. */
265
+ | { kind: "repo" }
266
+ /** No `.git` anchor found walking up from cwd — git itself will fail; the
267
+ * caller's existing error handling applies. */
268
+ | { kind: "no-git" }
269
+ /** cwd sits inside a linked worktree (nearest anchor is a `.git` POINTER
270
+ * FILE). `ok` is the gitdir-pointer verification result for that worktree. */
271
+ | { kind: "worktree"; ok: boolean; reason: string };
272
+
273
+ /**
274
+ * R-A3 (TOCTOU re-verify): cheap, self-anchored trust check for ANY git
275
+ * invocation about to run with `cwd`. Walk up from `cwd` to the NEAREST `.git`:
276
+ *
277
+ * - DIRECTORY → main-repo context (`cwd === repoRoot` and friends) → trusted,
278
+ * no pointer to verify — exactly the "cwd==repoRoot thì bỏ qua" rule;
279
+ * - FILE → cwd sits inside a linked worktree rooted at that level; its
280
+ * `.git` pointer is worker-writable, so it is re-verified against the
281
+ * git-registered admin dir. The TRUSTED common git dir is resolved by
282
+ * walking up from the worktree's PARENT — pi-crew worktrees live under
283
+ * `<repoRoot>/.crew/state/worktrees/…`, so the walk anchors at the
284
+ * orchestrator-owned main repo (never at the worktree's own pointer).
285
+ * If the walk finds NO anchor (foreign sibling shape — the worktree and
286
+ * its main repo are peers outside any orchestrator-owned tree, e.g. test
287
+ * fixtures under os.tmpdir()), the R-A3b mutual-linkage fallback decides
288
+ * instead of failing closed unconditionally (see
289
+ * {@link verifyForeignWorktreeLinkage}).
290
+ *
291
+ * Cost: a few lstats + the registry scan in verifyWorktreeGitdirPointer —
292
+ * deliberately paid before EVERY worktree-cwd git command so a live worker
293
+ * cannot flip the pointer after an entry-level check passed.
294
+ */
295
+ export function worktreeTrustCheck(cwd: string): WorktreeTrust {
296
+ let current = path.resolve(cwd);
297
+ let worktreeRoot: string | undefined;
298
+ // eslint-disable-next-line no-constant-condition
299
+ while (true) {
300
+ try {
301
+ const st = fs.lstatSync(path.join(current, ".git"));
302
+ // A plain pointer FILE (or anything that is not a directory, e.g. a
303
+ // symlink — which the pointer verification rejects fail-closed).
304
+ worktreeRoot = st.isDirectory() ? undefined : current;
305
+ break;
306
+ } catch {
307
+ /* no .git at this level — keep walking up */
308
+ }
309
+ const parent = path.dirname(current);
310
+ if (parent === current) return { kind: "no-git" };
311
+ current = parent;
312
+ }
313
+ if (worktreeRoot === undefined) return { kind: "repo" };
314
+ const trustedGitDir = resolveTrustedGitDir(path.dirname(worktreeRoot));
315
+ if (trustedGitDir) {
316
+ const check = verifyWorktreeGitdirPointer(worktreeRoot, trustedGitDir);
317
+ return { kind: "worktree", ok: check.ok, reason: check.reason };
318
+ }
319
+ // R-A3b foreign-worktree fallback: no trusted anchor above the worktree
320
+ // (strict-walk territory ends here). Decide via the weaker mutual-linkage
321
+ // verification instead of failing closed unconditionally — fixes the
322
+ // 0.11.45 regression where snapshotDirtyWorktree threw "cannot resolve a
323
+ // trusted git dir above …" for sibling-shaped worktree fixtures.
324
+ const fallback = verifyForeignWorktreeLinkage(worktreeRoot);
325
+ if (fallback.ok) {
326
+ logInternalError(
327
+ "worktree.trust.foreignFallback",
328
+ new Error("foreign linked worktree accepted via mutual-linkage fallback (no trusted anchor above)"),
329
+ `worktreePath=${worktreeRoot}`,
330
+ );
331
+ }
332
+ return { kind: "worktree", ok: fallback.ok, reason: fallback.reason };
333
+ }
334
+
335
+ /**
336
+ * R-A3b foreign-worktree fallback: mutual-linkage verification for linked
337
+ * worktrees whose parent-walk finds NO trusted `.git` anchor (the worktree and
338
+ * its main repo are SIBLINGS — the shape of test fixtures like
339
+ * `test/integration/worktree-snapshot-*`, or apps embedding these helpers
340
+ * against a repo outside the orchestrator's tree).
341
+ *
342
+ * Checks, in order (fail closed on any mismatch):
343
+ * 1. the worktree `.git` pointer file parses to `gitdir: <target>`;
344
+ * 2. SHAPE: `<target>` must be `<main>/.git/worktrees/<id>` — the layout git
345
+ * itself writes for a registered linked worktree (anything else is not a
346
+ * shape git produces and is rejected without further reads);
347
+ * 3. the two-way link, delegated to {@link verifyWorktreeGitdirPointer} with
348
+ * the pointer-derived candidate root: the admin backlink
349
+ * `<main>/.git/worktrees/<id>/gitdir` must point back at this worktree's
350
+ * `.git` AND the pointer must resolve to exactly that registered admin dir
351
+ * (realpath-normalized on both ends, identical to the strict path).
352
+ *
353
+ * SECURITY NOTE — this branch is strictly WEAKER than the strict parent-walk:
354
+ * an attacker controlling both endpoints in its own tree can fabricate a
355
+ * coherent pointer/backlink pair (the two-way link alone cannot distinguish
356
+ * that from a genuine git registration). It is accepted because non-owned
357
+ * paths do not occur in pi-crew's production runtime — pi-crew only runs git
358
+ * inside OWNED roots (`<repoRoot>/.crew/state/worktrees/…`, which always take
359
+ * the strict branch) and the main repo itself. The fallback exists to keep
360
+ * snapshot/cleanup capability for legitimately-shaped foreign worktrees
361
+ * (tests, embedded hosts) WITHOUT reopening the owned-root gitdir-hijack
362
+ * vector (MED-1).
363
+ */
364
+ function verifyForeignWorktreeLinkage(worktreePath: string): WorktreeGitdirCheck {
365
+ const dotGit = path.join(worktreePath, ".git");
366
+ let stat: fs.Stats;
367
+ try {
368
+ stat = fs.lstatSync(dotGit);
369
+ } catch {
370
+ return { ok: false, reason: `.git pointer missing at ${dotGit}` };
371
+ }
372
+ if (stat.isSymbolicLink()) {
373
+ return { ok: false, reason: `.git at ${worktreePath} is a symlink (expected a plain pointer file)` };
374
+ }
375
+ if (!stat.isFile()) {
376
+ return { ok: false, reason: `.git at ${worktreePath} is a directory (expected a linked-worktree pointer file)` };
377
+ }
378
+ let match: RegExpMatchArray | null = null;
379
+ try {
380
+ match = fs.readFileSync(dotGit, "utf-8").match(/^gitdir:\s*(.+)$/m);
381
+ } catch {
382
+ /* unreadable pointer → fail below */
383
+ }
384
+ if (!match) {
385
+ return { ok: false, reason: `.git pointer at ${dotGit} has no gitdir entry` };
386
+ }
387
+ const target = match[1]!.trim();
388
+ const absTarget = path.isAbsolute(target) ? target : path.resolve(worktreePath, target);
389
+ // Shape gate: pointer target must name a git-style admin dir
390
+ // `<main>/.git/worktrees/<id>` (relative targets resolved against the
391
+ // worktree, same as verifyWorktreeGitdirPointer).
392
+ const worktreesSegment = path.dirname(absTarget); // <main>/.git/worktrees
393
+ const dotGitSegment = path.dirname(worktreesSegment); // <main>/.git
394
+ const candidateMain = path.dirname(dotGitSegment); // <main>
395
+ if (path.basename(worktreesSegment) !== "worktrees" || path.basename(dotGitSegment) !== ".git") {
396
+ return { ok: false, reason: `.git pointer target ${absTarget} is not a <main>/.git/worktrees/<id> admin dir` };
397
+ }
398
+ // The candidate root's `.git` anchor must actually exist at that level —
399
+ // deliberately NOT a walk-up: an unrelated repo above the named root must
400
+ // never become the trust anchor of a pointer-chosen path.
401
+ try {
402
+ fs.statSync(path.join(candidateMain, ".git"));
403
+ } catch {
404
+ return { ok: false, reason: `pointer target root ${candidateMain} has no .git anchor` };
405
+ }
406
+ const candidateCommon = resolveTrustedGitDir(candidateMain);
407
+ if (!candidateCommon) {
408
+ return { ok: false, reason: `pointer target root ${candidateMain} has no resolvable common git dir` };
409
+ }
410
+ return verifyWorktreeGitdirPointer(worktreePath, candidateCommon);
411
+ }
412
+
413
+ /**
414
+ * Throwing form of {@link worktreeTrustCheck} (R-A3). Call at the top of every
415
+ * git helper: throws `worktree corrupted: …` when cwd sits inside a linked
416
+ * worktree whose `.git` pointer no longer matches the trusted registry, so the
417
+ * chain aborts before ANY further git runs inside that worktree. Trusted
418
+ * contexts (main repo / non-git cwd) are no-ops.
419
+ */
420
+ export function assertWorktreePointerTrusted(cwd: string): void {
421
+ const trust = worktreeTrustCheck(cwd);
422
+ if (trust.kind === "worktree" && !trust.ok) {
423
+ throw new Error(`worktree corrupted: refusing to run git in ${cwd} — gitdir pointer check failed (${trust.reason})`);
424
+ }
425
+ }
426
+
427
+ export interface WorktreeGitdirCheck {
428
+ ok: boolean;
429
+ reason: string;
430
+ }
431
+
432
+ /**
433
+ * Gitdir-hijack guard (MED-1). Verify — WITHOUT running git inside the
434
+ * worktree — that:
435
+ *
436
+ * 1. `worktreePath` is a REGISTERED worktree of `commonGitDir`: some
437
+ * `<commonGitDir>/worktrees/<admin>/gitdir` file (written by git, stored in
438
+ * the trusted main repo) points back at `worktreePath`; and
439
+ * 2. the worktree `.git` POINTER FILE (worker-writable) resolves to exactly
440
+ * that registered admin dir.
441
+ *
442
+ * Fail closed: ANY mismatch (pointer rewritten, `.git` is a symlink/directory,
443
+ * worktree not registered, registry unreadable) → ok:false. Callers must then
444
+ * refuse to run git with cwd inside the worktree; removal may only happen via
445
+ * `git worktree remove --force` from the trusted main-repo cwd.
446
+ */
447
+ export function verifyWorktreeGitdirPointer(worktreePath: string, commonGitDir: string): WorktreeGitdirCheck {
448
+ const worktreesRoot = path.join(commonGitDir, "worktrees");
449
+ let entries: fs.Dirent[];
450
+ try {
451
+ entries = fs.readdirSync(worktreesRoot, { withFileTypes: true });
452
+ } catch {
453
+ return { ok: false, reason: `worktree registry unreadable at ${worktreesRoot}` };
454
+ }
455
+ const realWorktree = realPathBestEffort(worktreePath);
456
+ let expectedAdmin: string | undefined;
457
+ for (const entry of entries) {
458
+ if (!entry.isDirectory()) continue;
459
+ let registered: string;
460
+ try {
461
+ registered = fs.readFileSync(path.join(worktreesRoot, entry.name, "gitdir"), "utf-8").trim();
462
+ } catch {
463
+ continue; // pruned/incomplete admin entry — not our worktree
464
+ }
465
+ // The registry file contains "<worktree>/.git"; compare worktree roots.
466
+ let registeredRoot = registered;
467
+ if (path.basename(registered) === ".git") registeredRoot = path.dirname(registered);
468
+ if (sameGitPath(realPathBestEffort(registeredRoot), realWorktree)) {
469
+ expectedAdmin = path.join(worktreesRoot, entry.name);
470
+ break;
471
+ }
472
+ }
473
+ if (expectedAdmin === undefined) {
474
+ return { ok: false, reason: `${worktreePath} is not a registered worktree of this repository` };
475
+ }
476
+ const dotGit = path.join(worktreePath, ".git");
477
+ let stat: fs.Stats;
478
+ try {
479
+ stat = fs.lstatSync(dotGit);
480
+ } catch {
481
+ return { ok: false, reason: `.git pointer missing at ${dotGit}` };
482
+ }
483
+ if (stat.isSymbolicLink()) {
484
+ return { ok: false, reason: `.git at ${worktreePath} is a symlink (expected a plain pointer file)` };
485
+ }
486
+ if (!stat.isFile()) {
487
+ return { ok: false, reason: `.git at ${worktreePath} is a directory (expected a linked-worktree pointer file)` };
488
+ }
489
+ let match: RegExpMatchArray | null = null;
490
+ try {
491
+ match = fs.readFileSync(dotGit, "utf-8").match(/^gitdir:\s*(.+)$/m);
492
+ } catch {
493
+ /* unreadable pointer → fail below */
494
+ }
495
+ if (!match) {
496
+ return { ok: false, reason: `.git pointer at ${dotGit} has no gitdir entry` };
497
+ }
498
+ const target = match[1]!.trim();
499
+ const absTarget = path.isAbsolute(target) ? target : path.resolve(worktreePath, target);
500
+ if (!sameGitPath(realPathBestEffort(absTarget), realPathBestEffort(expectedAdmin))) {
501
+ return {
502
+ ok: false,
503
+ reason: `.git pointer target ${absTarget} does not match the registered gitdir ${expectedAdmin} (possible gitdir hijack)`,
504
+ };
505
+ }
506
+ return { ok: true, reason: "" };
507
+ }