pi-crew 0.11.49 → 0.11.51

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/index.mjs +551 -544
  3. package/package.json +1 -1
  4. package/skills/real-test-pi-crew/SKILL.md +2 -0
  5. package/src/agents/agent-config.ts +58 -1
  6. package/src/config/config-validation.ts +18 -0
  7. package/src/config/env-allowlist-registry.ts +5 -1
  8. package/src/config/env-vars.ts +5 -1
  9. package/src/config/sanitize-project-config.ts +55 -0
  10. package/src/extension/management.ts +9 -0
  11. package/src/extension/registration/lifecycle-handlers.ts +28 -0
  12. package/src/extension/registration/subagent-tools.ts +5 -1
  13. package/src/extension/run-import.ts +40 -6
  14. package/src/extension/team-tool/api/agent-control.ts +49 -0
  15. package/src/extension/team-tool/api/heartbeat.ts +48 -1
  16. package/src/extension/team-tool/api/mailbox.ts +81 -2
  17. package/src/extension/team-tool/api/read.ts +22 -0
  18. package/src/extension/team-tool/api/task-claims.ts +134 -3
  19. package/src/extension/team-tool/destructive-gate.ts +92 -8
  20. package/src/extension/team-tool/inspect.ts +8 -2
  21. package/src/extension/team-tool/lifecycle-actions.ts +9 -2
  22. package/src/extension/team-tool.ts +128 -27
  23. package/src/observability/exporters/otlp-exporter.ts +69 -19
  24. package/src/prompt/message-tool.ts +19 -8
  25. package/src/prompt/prompt-runtime.ts +160 -51
  26. package/src/runtime/broker/crew-broker-child.ts +55 -17
  27. package/src/runtime/broker/crew-broker-tokens.ts +24 -13
  28. package/src/runtime/broker/crew-broker.ts +12 -6
  29. package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
  30. package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
  31. package/src/runtime/child-pi/child-pi-steering.ts +8 -1
  32. package/src/runtime/child-pi/child-pi.ts +2 -1
  33. package/src/runtime/child-pi/inference-broker.ts +41 -7
  34. package/src/runtime/child-pi/pi-version.ts +31 -3
  35. package/src/runtime/child-pi/sandbox.ts +38 -1
  36. package/src/runtime/dwf-state-store.ts +193 -7
  37. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
  38. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
  39. package/src/runtime/live-session/live-session-runtime.ts +78 -3
  40. package/src/runtime/mcp-proxy.ts +65 -6
  41. package/src/runtime/model/pi-args.ts +16 -15
  42. package/src/runtime/plan-replan.ts +4 -1
  43. package/src/runtime/task-runner/child-executor.ts +11 -5
  44. package/src/runtime/task-runner/hybrid-tier.ts +17 -0
  45. package/src/runtime/task-runner/pre-execution.ts +13 -6
  46. package/src/runtime/task-runner.ts +14 -0
  47. package/src/runtime/team-runner.ts +26 -0
  48. package/src/runtime/verification/verification-worktree.ts +46 -29
  49. package/src/runtime/verification/verify-gate-memo.ts +211 -32
  50. package/src/runtime/verification/verify-gate.ts +266 -14
  51. package/src/runtime/workspace-lock.ts +95 -21
  52. package/src/schema/config-schema.ts +12 -2
  53. package/src/state/coordination/locks.ts +86 -19
  54. package/src/state/coordination/mailbox.ts +263 -22
  55. package/src/state/stores/manifest-io.ts +206 -7
  56. package/src/state/stores/state-store.ts +44 -15
  57. package/src/state/types.ts +9 -0
  58. package/src/ui/inline-panel/agent-transcript.ts +151 -11
  59. package/src/ui/settings-overlay.ts +8 -3
  60. package/src/ui/theme-discovery.ts +14 -2
  61. package/src/ui/tool-renderers/index.ts +26 -6
  62. package/src/utils/safe-terminal-text.ts +60 -0
  63. package/src/worktree/git-hardening.ts +78 -1
  64. package/src/worktree/worktree-manager.ts +34 -4
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import * as fs from "node:fs";
23
23
  import { atomicWriteFile } from "../state/atomic-write.ts";
24
+ import { sanitizeUntrustedLine } from "../utils/visual.ts";
24
25
  import { getAllThemes } from "./pi-ui-compat.ts";
25
26
 
26
27
  /**
@@ -125,13 +126,20 @@ function discoverPiThemesFromFs(): PiThemeInfo[] {
125
126
  for (const file of fs.readdirSync(dir) as string[]) {
126
127
  if (!file.endsWith(".json")) continue;
127
128
  const name = file.slice(0, -5);
129
+ // w7a-render (control-char-theme-name-render): ~/.pi/agent/themes is
130
+ // worker-writable, so a filename stem carrying control/escape chars
131
+ // (OSC-52/OSC-8/CSI) is attacker-plantable. Refuse the file entirely —
132
+ // it can never become a picker value or a listing line.
133
+ if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) continue;
128
134
  if (seen.has(name)) continue;
129
135
  const fullPath = `${dir}/${file}`;
130
136
  let displayName: string | undefined;
131
137
  let mode: "dark" | "light" | undefined;
132
138
  try {
133
139
  const json = JSON.parse(fs.readFileSync(fullPath, "utf8"));
134
- displayName = typeof json.name === "string" ? json.name : undefined;
140
+ // The JSON body is worker-writable too — the human-friendly name is
141
+ // untrusted display text (w7a-render defense in depth).
142
+ displayName = typeof json.name === "string" ? sanitizeUntrustedLine(json.name) : undefined;
135
143
  mode = detectThemeMode(json);
136
144
  } catch {
137
145
  // keep undefined
@@ -256,5 +264,9 @@ function themeLine(t: PiThemeInfo, active: string | undefined): string {
256
264
  const isActive = t.name === active;
257
265
  const tag = isActive ? " ← active" : "";
258
266
  const src = t.source === "builtin" ? " (builtin)" : "";
259
- return ` ${isActive ? "●" : "○"} ${t.name}${src}${tag}`;
267
+ // w7a-render: theme names derived from worker-writable files (fs scan or
268
+ // host theme list) are untrusted display text — sanitize before the
269
+ // listing becomes tool-result content (R4-B1). Identity comparison above
270
+ // stays on the raw name so switching keeps working.
271
+ return ` ${isActive ? "●" : "○"} ${sanitizeUntrustedLine(t.name)}${src}${tag}`;
260
272
  }
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { type Container, Text, visibleWidth } from "@earendil-works/pi-tui";
29
29
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
30
+ import { sanitizeUntrustedLine } from "../../utils/visual.ts";
30
31
  import { AdaptiveCard } from "../adaptive-card.ts";
31
32
  import type { CrewComponent } from "../component.ts";
32
33
  import { formatCount, formatDuration, formatTokens, truncLine } from "../format-helpers.ts";
@@ -147,8 +148,12 @@ function shortRun(runId: string | undefined): string {
147
148
  /** Dim preview line (goals, prompts, output snippets) with visual truncation. */
148
149
  function previewLine(glyph: string, slot: RailSlot, text: string, theme: CrewTheme, budget: number, shorten = false): string {
149
150
  const flat = (shorten ? shortenPath(text) : text).replace(/\n/g, " ");
151
+ // w7a-render (worker-output-render-unsanitized): worker/repo-derived text is
152
+ // sanitized BEFORE theme wrap (R4-B1) — pi-tui zero-widths escape payloads
153
+ // so visible-width truncation would never cut an in-window OSC/CSI payload.
154
+ const safe = sanitizeUntrustedLine(flat);
150
155
  const maxLen = budget - 2;
151
- const body = visibleWidth(flat) > maxLen ? `${truncVisual(flat, maxLen - 1)}…` : flat;
156
+ const body = visibleWidth(safe) > maxLen ? `${truncVisual(safe, maxLen - 1)}…` : safe;
152
157
  return railLine(glyph, slot, theme.fg("dim", body), theme, budget);
153
158
  }
154
159
 
@@ -204,7 +209,9 @@ function renderTeamResult(result: Record<string, unknown>, options: unknown, the
204
209
  // ── Streaming (partial result, collapsed view) ──
205
210
  const isPartial = (options as Record<string, unknown>)?.isPartial === true;
206
211
  if (isPartial && !ctx.expanded) {
207
- const parsed = parseStreamingProgress(extractContentText(result?.content));
212
+ // w7a-render: progress text is a worker-derived channel — sanitize before
213
+ // parsing so every meta fragment derived from it (status/activeAgent) is escape-free.
214
+ const parsed = parseStreamingProgress(sanitizeUntrustedLine(extractContentText(result?.content)));
208
215
  if (parsed) {
209
216
  const spinner = theme.fg("accent", spinnerFrame(String(spinnerClockNow())));
210
217
  const elapsed = theme.fg("dim", formatDuration(parsed.elapsedMs));
@@ -229,7 +236,13 @@ function renderTeamResult(result: Record<string, unknown>, options: unknown, the
229
236
  }
230
237
  const fallback = extractContentText(result?.content).split("\n").filter(Boolean).pop();
231
238
  if (fallback) {
232
- return railLine(RAIL.body, "borderAccent", `${theme.fg("warning", "◉")} ${theme.fg("dim", fallback)}`, theme, budget);
239
+ return railLine(
240
+ RAIL.body,
241
+ "borderAccent",
242
+ `${theme.fg("warning", "◉")} ${theme.fg("dim", sanitizeUntrustedLine(fallback))}`,
243
+ theme,
244
+ budget,
245
+ );
233
246
  }
234
247
  }
235
248
 
@@ -402,7 +415,7 @@ function renderAgentResult(result: Record<string, unknown>, options: unknown, th
402
415
  const name = (d.agentName as string) ?? (d.agentId as string) ?? "agent";
403
416
  const spinner = theme.fg("accent", spinnerFrame(String(d.agentId ?? "")));
404
417
  const meta: string[] = [];
405
- const progressText = extractContentText(result?.content);
418
+ const progressText = sanitizeUntrustedLine(extractContentText(result?.content));
406
419
  if (progressText) {
407
420
  const parsed = parseCompactToolProgress(progressText);
408
421
  const elapsedSec = parsed ? Math.round(parsed.elapsedMs / 1000) : 0;
@@ -430,13 +443,20 @@ function renderAgentResult(result: Record<string, unknown>, options: unknown, th
430
443
  const badge = statusBadge(status, theme);
431
444
  const head = `${badge} ${theme.fg("toolTitle", theme.bold(label))}`;
432
445
  if (d.error) {
433
- return railLine(RAIL.close, "error", `${head} ${theme.fg("error", truncLine(String(d.error), budget - 24))}`, theme, budget);
446
+ // w7a-render: task.error can embed child-process stderr excerpts (R4-B1).
447
+ return railLine(
448
+ RAIL.close,
449
+ "error",
450
+ `${head} ${theme.fg("error", truncLine(sanitizeUntrustedLine(String(d.error)), budget - 24))}`,
451
+ theme,
452
+ budget,
453
+ );
434
454
  }
435
455
  const output = results?.length ? ((results[0] as Record<string, unknown>).output as string | undefined) : undefined;
436
456
  const preview = output ? output.split("\n").find((l) => l.trim()) : undefined;
437
457
  const right = theme.fg("dim", PI_EXPAND_CHORD);
438
458
  if (preview) {
439
- const left = `${head} ${theme.fg("muted", truncLine(preview, Math.max(8, budget - visibleWidth(head) - 8)))}`;
459
+ const left = `${head} ${theme.fg("muted", truncLine(sanitizeUntrustedLine(preview), Math.max(8, budget - visibleWidth(head) - 8)))}`;
440
460
  return railLine(RAIL.close, closeSlot, railLeaders(left, right, budget, theme), theme, budget);
441
461
  }
442
462
  // Live 2026-09-16: with no output preview the cap printed a bare
@@ -0,0 +1,60 @@
1
+ /**
2
+ * safe-terminal-text.ts — neutralize terminal escape sequences in untrusted
3
+ * text (w7a-render, 2026-10-29).
4
+ *
5
+ * Worker-derived strings (inline-panel transcript text/tool names, theme
6
+ * filenames under ~/.pi/agent/themes, inspect error excerpts) can carry
7
+ * OSC/APC/DCS/SOS string sequences (OSC 52 clipboard writes, OSC 8
8
+ * hyperlinks, OSC 0 title sets) and CSI/C1 sequences (screen clear, cursor
9
+ * moves, DA1 response spoofing) that pi-tui writes to the operator terminal
10
+ * verbatim — pi-tui zero-widths escape payloads, so its visible-width crash
11
+ * guard never trips and an in-window payload reaches terminal.write
12
+ * byte-for-byte.
13
+ *
14
+ * Unlike `sanitizeUntrustedLine` (utils/visual.ts) — the single-line sink
15
+ * helper that maps ALL C0/C1 controls (including newlines) to spaces — this
16
+ * helper preserves printable structure (newlines, tabs) so multi-line
17
+ * transcript text keeps its shape; it ONLY removes escape-sequence bytes:
18
+ * - OSC/APC/DCS/SOS string sequences, BEL- (`\u0007`), ST- (`ESC \`) and
19
+ * C1-ST- (`\u009C`) terminated;
20
+ * - CSI and other ESC-intermediate sequences, including the C1 CSI form
21
+ * (`\u009B`);
22
+ * - a final sweep removes any residual ESC/C1 introducer bytes (e.g.
23
+ * sequences truncated mid-stream by compaction) so no control byte can
24
+ * survive.
25
+ * SGR color sequences are stripped too: trusted components (pi-tui theme
26
+ * adapters) apply their own styling AFTER this boundary, so no
27
+ * worker-supplied styling byte survives.
28
+ */
29
+
30
+ /**
31
+ * OSC (`ESC ]`), APC (`ESC _`), DCS (`ESC P`), SOS (`ESC X`) and PM (`ESC ^`)
32
+ * string sequences … BEL / ST (`ESC \`)/ C1-ST (`\u009C`) terminators.
33
+ */
34
+ const TERMINAL_STRING_SEQUENCE_RE =
35
+ /(?:\u001B[\]_PX^][\s\S]*?(?:\u0007|\u001B\\|\u009C)|[\u001B\u009B][[\]()#;?]*(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~])/g;
36
+
37
+ /** Residual ESC/C1 introducer bytes (truncated sequences, malformed forms). */
38
+ const RESIDUAL_INTRODUCER_RE = /[\u001B\u009B\u009C]/g;
39
+
40
+ /** Fast path: a string without any introducer byte cannot carry a sequence. */
41
+ function hasIntroducer(value: string): boolean {
42
+ return value.includes("\u001B") || value.includes("\u009B") || value.includes("\u009C");
43
+ }
44
+
45
+ /** True when the value contains bytes that only appear inside escape sequences. */
46
+ export function hasTerminalEscapeBytes(value: string): boolean {
47
+ return hasIntroducer(value);
48
+ }
49
+
50
+ /**
51
+ * Neutralize terminal escape sequences in untrusted text.
52
+ * Returns a string with zero ESC/C1 control bytes: OSC/APC/DCS/SOS payloads
53
+ * are removed wholesale (when terminated), CSI/ESC-intermediate sequences are
54
+ * removed, and any residual introducer byte is dropped. Printable text
55
+ * (including newlines/tabs) passes through unchanged.
56
+ */
57
+ export function stripTerminalSequences(value: string): string {
58
+ if (!hasIntroducer(value)) return value;
59
+ return value.replace(TERMINAL_STRING_SEQUENCE_RE, "").replace(RESIDUAL_INTRODUCER_RE, "");
60
+ }
@@ -206,6 +206,63 @@ export function resolveTrustedGitDir(startPath: string): string | undefined {
206
206
  * without breaking legitimate filters — the worker-reachable path to a
207
207
  * POISONED definition (gitdir hijack) is what the pointer guards block.
208
208
  */
209
+ /** EOL semantics carried by the SYSTEM gitconfig (Git-for-Windows ships
210
+ * `core.autocrlf=true` there — the install-wide default). Only the two
211
+ * value-domain-checked keys are ever forwarded (see buildGitSafeEnv). */
212
+ interface SystemCoreEol {
213
+ autocrlf?: "true" | "false" | "input";
214
+ eol?: "lf" | "crlf" | "native";
215
+ }
216
+
217
+ /** Strict parser for the system gitconfig's [core] eol semantics. Deliberately
218
+ * NOT a gitconfig interpreter: no includes, no interpolation, no other keys —
219
+ * a poisoned system config can contribute NOTHING but these two validated
220
+ * scalars. Unparseable/invalid values are dropped (fail to no-injection). */
221
+ export function parseSystemCoreEol(text: string): SystemCoreEol {
222
+ const result: SystemCoreEol = {};
223
+ let inCore = false;
224
+ for (const rawLine of text.split(/\r?\n/)) {
225
+ const line = rawLine.replace(/[\t ];*[#;].*$/, "").trim();
226
+ if (line.length === 0) continue;
227
+ const section = line.match(/^\[([A-Za-z0-9-.]+)\]$/);
228
+ if (section) {
229
+ inCore = section[1]!.toLowerCase() === "core";
230
+ continue;
231
+ }
232
+ if (!inCore) continue;
233
+ const kv = line.match(/^([A-Za-z0-9-]+)\s*=\s*(\S+)$/);
234
+ if (!kv) continue;
235
+ const key = kv[1]!.toLowerCase();
236
+ const value = kv[2]!.toLowerCase();
237
+ if (key === "autocrlf" && (value === "true" || value === "false" || value === "input")) {
238
+ result.autocrlf = value;
239
+ } else if (key === "eol" && (value === "lf" || value === "crlf" || value === "native")) {
240
+ result.eol = value;
241
+ }
242
+ }
243
+ return result;
244
+ }
245
+
246
+ /** Best-effort read of the platform's DEFAULT system gitconfig eol semantics.
247
+ * The `GIT_CONFIG_SYSTEM` env var is deliberately NOT honored — forwarding it
248
+ * is exactly the R-A4a vector — so a poisoned environment cannot redirect
249
+ * this read; only git's canonical install paths are consulted. */
250
+ function readSystemGitConfigEol(): SystemCoreEol {
251
+ let configPath: string | undefined;
252
+ if (process.platform === "win32") {
253
+ const programFiles = process.env["ProgramFiles"] ?? process.env["ProgramFiles(x86)"];
254
+ if (programFiles) configPath = path.join(programFiles, "Git", "etc", "gitconfig");
255
+ } else {
256
+ configPath = "/etc/gitconfig";
257
+ }
258
+ if (!configPath) return {};
259
+ try {
260
+ return parseSystemCoreEol(fs.readFileSync(configPath, "utf-8"));
261
+ } catch {
262
+ return {};
263
+ }
264
+ }
265
+
209
266
  export function buildGitSafeEnv(locale: "C" | "en_US.UTF-8" = "C"): Record<string, string> {
210
267
  const allowList = [
211
268
  "PATH",
@@ -247,7 +304,7 @@ export function buildGitSafeEnv(locale: "C" | "en_US.UTF-8" = "C"): Record<strin
247
304
  if (value === undefined) continue;
248
305
  normalizedEnv[canonicalCase.get(key.toUpperCase()) ?? key] = value;
249
306
  }
250
- return {
307
+ const safeEnv: Record<string, string> = {
251
308
  ...sanitizeEnvSecrets(normalizedEnv, { allowList }),
252
309
  LANG: locale,
253
310
  LC_ALL: locale,
@@ -256,6 +313,26 @@ export function buildGitSafeEnv(locale: "C" | "en_US.UTF-8" = "C"): Record<strin
256
313
  // UNCONDITIONALLY on every platform (git for Windows honours it too).
257
314
  GIT_CONFIG_NOSYSTEM: "1",
258
315
  };
316
+ // Windows-CI live probe (2026-10-30, run 41058674711): with NOSYSTEM the
317
+ // Git-for-Windows install-wide `core.autocrlf=true` (which lives in the
318
+ // SYSTEM gitconfig, not the global one) vanishes from git's eol model — a
319
+ // pristine worktree checked out with CRLF then compares CRLF-vs-LF and
320
+ // `git status` reports phantom `M <file>` on every file → every hardened
321
+ // dirty probe fails CLOSED on clean trees (verify-gate skip refusals, memo
322
+ // disabled, snapshot dirty). Forward ONLY the two eol scalars, parsed and
323
+ // value-domain-checked by trusted code above, via git's env-config channel
324
+ // (GIT_CONFIG_COUNT — honored independently of NOSYSTEM, git>=2.31). No
325
+ // include, no filter, no other system key can travel this channel.
326
+ const systemEol = readSystemGitConfigEol();
327
+ const eolEntries = Object.entries(systemEol) as ["autocrlf" | "eol", string][];
328
+ if (eolEntries.length > 0) {
329
+ safeEnv.GIT_CONFIG_COUNT = String(eolEntries.length);
330
+ eolEntries.forEach(([key, value], index) => {
331
+ safeEnv[`GIT_CONFIG_KEY_${index}`] = `core.${key}`;
332
+ safeEnv[`GIT_CONFIG_VALUE_${index}`] = value;
333
+ });
334
+ }
335
+ return safeEnv;
259
336
  }
260
337
 
261
338
  /** Result of the R-A3 per-command trust check (see {@link worktreeTrustCheck}). */
@@ -11,6 +11,7 @@ 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 { resolveRealContainedPath } from "../utils/safe-paths.ts";
14
15
  import {
15
16
  assertWorktreePointerTrusted,
16
17
  buildGitSafeEnv,
@@ -574,13 +575,22 @@ export function normalizeSeedPaths(seedPaths: string[], repoRoot: string): strin
574
575
  * Overlay seed paths from repoRoot into worktreePath.
575
576
  * Copies files and directories, creating parent dirs as needed.
576
577
  * Skips non-existent sources with logInternalError (non-fatal).
578
+ *
579
+ * Destination-side safety (W6 fix, seedpaths-dest-symlink-ancestor): the
580
+ * destination is resolved through an O_NOFOLLOW ancestor walk rooted at the
581
+ * worktree path BEFORE any destructive/copying filesystem operation. A
582
+ * symlinked component under the worktree — materialized by `git worktree add`
583
+ * from a repo-committed symlink, or planted by a prior worker and surviving a
584
+ * reused worktree's preserve branch — must never redirect the rmSync/cpSync
585
+ * (which run in the HOST process, outside any worker sandbox) to a path
586
+ * outside the worktree. Such entries are skipped (fail closed) with
587
+ * logInternalError.
577
588
  */
578
589
  export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPaths: string[]): void {
579
590
  const normalized = normalizeSeedPaths(seedPaths, repoRoot);
580
591
 
581
592
  for (const seedPath of normalized) {
582
593
  const sourcePath = path.join(repoRoot, seedPath);
583
- const destinationPath = path.join(worktreePath, seedPath);
584
594
 
585
595
  let sourceStat: fs.Stats;
586
596
  try {
@@ -599,9 +609,29 @@ export function overlaySeedPaths(repoRoot: string, worktreePath: string, seedPat
599
609
  continue;
600
610
  }
601
611
 
602
- fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
603
- fs.rmSync(destinationPath, { force: true, recursive: true });
604
- fs.cpSync(sourcePath, destinationPath, {
612
+ // Resolve the destination through the O_NOFOLLOW ancestor walk instead of
613
+ // a plain lexical path.join: refuse (fail closed) when the resolved path
614
+ // escapes the worktree or ANY existing ancestor component is a symlink.
615
+ // A non-existent destination leaf is fine — the overlay creates it.
616
+ let safeDestination: string;
617
+ try {
618
+ safeDestination = resolveRealContainedPath(worktreePath, seedPath);
619
+ // Create parent directories only AFTER validation, then re-validate:
620
+ // mkdirSync itself walks the (validated) chain to create the missing
621
+ // tail, but a concurrent planter must not win the gap between the two
622
+ // validations.
623
+ fs.mkdirSync(path.dirname(safeDestination), { recursive: true });
624
+ safeDestination = resolveRealContainedPath(worktreePath, seedPath);
625
+ } catch {
626
+ logInternalError(
627
+ "worktree.seedPaths.destinationUnsafe",
628
+ new Error(`Seed destination escapes worktree or contains symlinked components — rejected: ${seedPath}`),
629
+ );
630
+ continue;
631
+ }
632
+
633
+ fs.rmSync(safeDestination, { force: true, recursive: true });
634
+ fs.cpSync(sourcePath, safeDestination, {
605
635
  dereference: true,
606
636
  force: true,
607
637
  preserveTimestamps: true,