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.
- package/CHANGELOG.md +45 -1
- package/dist/index.mjs +504 -504
- package/package.json +1 -1
- package/src/extension/cross-extension-rpc.ts +15 -6
- package/src/runtime/broker/crew-broker.ts +40 -33
- package/src/runtime/broker/socket-stop.ts +147 -0
- package/src/runtime/child-pi/child-pi-kill.ts +44 -0
- package/src/runtime/child-pi/inference-broker.ts +40 -8
- package/src/runtime/goal-workflow/goal-achievement.ts +10 -1
- package/src/runtime/pi-spawn.ts +10 -1
- package/src/runtime/task-runner/post-execution.ts +36 -2
- package/src/utils/socket-path.ts +54 -0
- package/src/worktree/branch-freshness.ts +9 -22
- package/src/worktree/cleanup.ts +78 -65
- package/src/worktree/git-hardening.ts +507 -0
- package/src/worktree/worktree-manager.ts +176 -129
|
@@ -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
|
+
}
|