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.
- package/CHANGELOG.md +116 -0
- package/dist/index.mjs +551 -544
- package/package.json +1 -1
- package/skills/real-test-pi-crew/SKILL.md +2 -0
- package/src/agents/agent-config.ts +58 -1
- package/src/config/config-validation.ts +18 -0
- package/src/config/env-allowlist-registry.ts +5 -1
- package/src/config/env-vars.ts +5 -1
- package/src/config/sanitize-project-config.ts +55 -0
- package/src/extension/management.ts +9 -0
- package/src/extension/registration/lifecycle-handlers.ts +28 -0
- package/src/extension/registration/subagent-tools.ts +5 -1
- package/src/extension/run-import.ts +40 -6
- package/src/extension/team-tool/api/agent-control.ts +49 -0
- package/src/extension/team-tool/api/heartbeat.ts +48 -1
- package/src/extension/team-tool/api/mailbox.ts +81 -2
- package/src/extension/team-tool/api/read.ts +22 -0
- package/src/extension/team-tool/api/task-claims.ts +134 -3
- package/src/extension/team-tool/destructive-gate.ts +92 -8
- package/src/extension/team-tool/inspect.ts +8 -2
- package/src/extension/team-tool/lifecycle-actions.ts +9 -2
- package/src/extension/team-tool.ts +128 -27
- package/src/observability/exporters/otlp-exporter.ts +69 -19
- package/src/prompt/message-tool.ts +19 -8
- package/src/prompt/prompt-runtime.ts +160 -51
- package/src/runtime/broker/crew-broker-child.ts +55 -17
- package/src/runtime/broker/crew-broker-tokens.ts +24 -13
- package/src/runtime/broker/crew-broker.ts +12 -6
- package/src/runtime/broker/protocol/wait-resolve.ts +15 -1
- package/src/runtime/child-pi/child-pi-spawn.ts +36 -10
- package/src/runtime/child-pi/child-pi-steering.ts +8 -1
- package/src/runtime/child-pi/child-pi.ts +2 -1
- package/src/runtime/child-pi/inference-broker.ts +41 -7
- package/src/runtime/child-pi/pi-version.ts +31 -3
- package/src/runtime/child-pi/sandbox.ts +38 -1
- package/src/runtime/dwf-state-store.ts +193 -7
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +48 -7
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +82 -6
- package/src/runtime/live-session/live-session-runtime.ts +78 -3
- package/src/runtime/mcp-proxy.ts +65 -6
- package/src/runtime/model/pi-args.ts +16 -15
- package/src/runtime/plan-replan.ts +4 -1
- package/src/runtime/task-runner/child-executor.ts +11 -5
- package/src/runtime/task-runner/hybrid-tier.ts +17 -0
- package/src/runtime/task-runner/pre-execution.ts +13 -6
- package/src/runtime/task-runner.ts +14 -0
- package/src/runtime/team-runner.ts +26 -0
- package/src/runtime/verification/verification-worktree.ts +46 -29
- package/src/runtime/verification/verify-gate-memo.ts +211 -32
- package/src/runtime/verification/verify-gate.ts +266 -14
- package/src/runtime/workspace-lock.ts +95 -21
- package/src/schema/config-schema.ts +12 -2
- package/src/state/coordination/locks.ts +86 -19
- package/src/state/coordination/mailbox.ts +263 -22
- package/src/state/stores/manifest-io.ts +206 -7
- package/src/state/stores/state-store.ts +44 -15
- package/src/state/types.ts +9 -0
- package/src/ui/inline-panel/agent-transcript.ts +151 -11
- package/src/ui/settings-overlay.ts +8 -3
- package/src/ui/theme-discovery.ts +14 -2
- package/src/ui/tool-renderers/index.ts +26 -6
- package/src/utils/safe-terminal-text.ts +60 -0
- package/src/worktree/git-hardening.ts +78 -1
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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,
|