chore(#4654): add local/no-unconfined-path-join and drain it to zero — Phase 4 of #4636 (#4674)

* chore(#4654): add local/no-unconfined-path-join and drain it to zero

Phase 4 of epic #4636 — the ratchet, and the phase that makes the epic hold.

THE MEASUREMENT THAT RESHAPED THE PHASE. An AST census (the repo's own parser,
not grep) found what the epic never enumerated: ADR-4650 named seven containment
implementations; `src/` alone held roughly 24 more hand-rolled gates across ~13
files, several guarding a write or an `fs.rmSync`. Two verified by reading rather
than pattern-matching — `research-store.cts` comments its own as "ensure the
resolved file path stays inside the store dir" immediately before a write, and
`capability-lifecycle.cts` gates `fs.rmSync` with one.

So the epic's Done-when "one containment predicate, used at every site" was FALSE
when Phase 3 reported it satisfied. It is true now: the rule is clean across
src/, scripts/, gsd-core/bin/ and hooks/ with an EMPTY allowlist.

WHY NOT THE RULE THE ISSUE PROPOSED. #4654 proposed flagging `path.join` whose
first argument is a managed root and whose later arguments derive from argv. That
is a taint analysis over 2046 call sites, in ESLint, without type information;
"derives from argv" is not locally decidable. Any approximation either floods or
is trivially evaded, and a rule that fires on hundreds of correct sites earns an
allowlist of hundreds — the opposite of a ratchet. What is actually duplicated is
the COMPARISON, not the join, and that has one recognizable shape.

  Arm 1  X.startsWith(Y + sep)            the hand-rolled containment idiom
  Arm 2  a containment predicate called as a bare statement, answer discarded

Arm 2 is the issue's "asserts the result was narrowed, not merely that a helper
was called". Its example `validatePath(x, root).resolved` is already
structurally impossible — Phase 3 un-exported `validatePath` — so the remaining
expressible failure is ignoring the answer, which is the defect that recurred
five times in this epic. The census found exactly one live instance
(`milestone.cts:1643`); it now returns the proven `ContainedPath` so consumers
stop re-deriving the path the comment above it was extracted to stop them
re-deriving.

The rule deliberately does NOT try to catch validate-one-path-use-another where
the answer is used but a different variable flows onward. That needs flow
analysis; the branded `ContainedPath` from Phase 3 is the defense there, and the
two are complementary.

PER-SITE FAMILY CHOICE, NOT A DEFAULT. Phase 3's lesson binds: collapsing a
lexical site onto the realpath family broke four tests and was caught only by the
matrix. Every migrated site was triaged individually. The six
installer-migrations tree-walks and the six capability-lifecycle gates take the
LEXICAL family because their operands are already realpath-resolved and they
deliberately treat the final component as a link; boundary sites take realpath.

TWO SITES WITH AN INVERTED CONTRACT, which a mechanical swap would have broken.
`installer-migrations.cts:127` and `runtime-artifact-install-plan.cts:144` REJECT
`target === root` by contract, while the canonical comparison ACCEPTS it. Swapped
naively, a migration could `rmdir` the user's config root and a third-party
descriptor could write at configHome itself. Both keep `=== root` as an explicit
additional arm alongside the predicate call — the predicate decides containment,
the call site keeps its own extra condition (ADR-4650 decision 6).

ONE DUPLICATE DELETED OUTRIGHT: `planning-inspect.cts`'s `isWithinRoot` was
byte-identical to `isContainedIn` and said so in its own docstring.
`isContainedIn` is now exported for callers that have already resolved both
operands and need only the comparison, with a doc note that a caller which has
NOT resolved them must use a full predicate instead.

THE MARKER, AND WHY IT IS NOT THE ALLOWLIST. Nine sites are justified holdouts and
carry `// allow-handrolled-containment: <reason>` with a mandatory, reviewable
reason. Two justifications: (a) not a containment decision — an ancestor-walk loop
condition, sub-repo grouping, worktree identity matching, declared-path coverage;
(b) it IS containment but the canonical predicate is unreachable —
`capability-validator.cjs` is a committed pre-build `.cjs` and the compiled
`security.cjs` is untracked build output, so requiring it would break a fresh
clone. `scripts/lib/drift-scan.cjs` runs under `lint:ci` with the same exposure.
The marker was renamed from `allow-lexical-prefix-match` mid-phase because that
name asserted only (a) and would have stated something false at the (b) sites.

A marker suppresses BEFORE the violation counter increments, so a file whose
every occurrence is marked still reports `staleAllowlistEntry` — otherwise a
drained entry lingers and silently re-permits the site later.

DEMONSTRATED RED, per #4654: a hand-rolled copy reintroduced into a real `src/`
file made `npm run lint` fail with the rule's full guidance message; removing it
returned the tree to clean. Both halves recorded — red alone proves nothing,
since a rule red for an unrelated reason looks identical.

DISCLOSED: `defaultRequireFromInstallRoot` (gsd-tools.cjs) previously carried two
distinct rejection messages and two manual realpath calls; routing it through
`tryWithinRoot` collapses them to one message, and a missing module now surfaces
as MODULE_NOT_FOUND rather than ENOENT. No test asserts either message. The
security property is preserved and slightly strengthened — the candidate is
realpathed and containment re-checked, and the dangling-symlink oracle closure
comes along with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#4654): record the containment ratchet in CONTEXT.md and the security model

Both entries previously described the seam without the thing that keeps it a
seam. They now state what the rule bans, and — more usefully for whoever reads
this next — what it deliberately does NOT attempt: deciding per path.join call
whether an argument came from user input. That question is not locally
decidable, and an approximation across ~2000 join sites would earn an exemption
list of hundreds, which is the opposite of a ratchet.

Also records the marker's two legitimate justifications and that its reason is
mandatory, so the escape stays reviewable rather than becoming a mute button.

Glossary gate 270 refs exit 0; install-tree goldens and CONTEXT-INDEX.json
regenerated and confirmed byte-identical rather than assumed — which also
confirms eslint-rules/ is not a shipped path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4654): close review findings and the two matrix failures

MATRIX FAILURE 1 — a collapsed message broke a negative-proof test, and my
evidence for collapsing it was wrong. I searched tests/ for the literal string
"resolves outside its install root", found nothing, and reported that no test
asserted it. The test matches a REGEX SUBSTRING, /outside its install root/, so
the literal search missed it. What broke was "NEGATIVE PROOF: a symlinked module
pointing OUTSIDE the install root is not loaded" — the test guarding the exact
property I claimed was preserved. defaultRequireFromInstallRoot now does both
checks again with both messages byte-identical, each routed through the
canonical predicate, which is better than the original since that hand-rolled
both comparisons.

MATRIX FAILURE 2 — shipped migrations are checksum-locked, and a marker cannot
serve there. migrationChecksum hashes plan.toString(), which INCLUDES comments,
so a suppression marker inside a plan body drifts the baseline exactly as an
edit does. Measured: with markers in place, two of the four still differed from
their committed checksums. The four shipped bodies are now byte-identical to
next, and the rule's config excludes those four paths BY NAME rather than by a
directory wildcard, so a NEW migration is still covered. Six containment
comparisons stay un-ratcheted there; that gap is recorded in the rule's Known
gaps, in CONTEXT.md and in the security model rather than left implicit.
Justification (c) is removed from the marker's documented reasons, because a
marker was proven unable to express it.

ADVERSARIAL REVIEW — the sharpest finding was that the rule banned the CORRECT
shape while permitting the incorrect one: startsWith(root) with no separator is
the genuinely unsafe form, since it accepts a sibling such as root-evil, and my
own test blessed it as valid. Flagging every bare startsWith would swamp the
rule, so that stays a STATED gap rather than a silent one. Closed for real: the
template-literal spelling, which the census never saw because it only inspected
plus-concatenation — that surfaced TWELVE more sites, now triaged and migrated.
A separator reached through a const alias is now resolved via scope analysis.
And isContainedIn, exported in Phase 3, was missing from the discarded-result
set, so a bare no-op call went unflagged on the one function the epic funnels
through.

SECURITY REVIEW — the marker could over-suppress two ways: a block comment
worked identically to a line comment, and one marker silently covered every
violation sharing its line. It now requires a Line comment positioned after the
flagged node ends, so it anchors to the node it trails. Four sites had dropped
an unreachable-but-deliberate equality rejection against the root; each is
restored as the call site's own arm. eslint.config.mjs still documented the OLD
marker token, which my rename missed — it would have sent the next author in
circles.

A FALSE GREEN, recorded because it nearly stuck: lint:ci reported exit 0 from a
stale eslint cache while twelve real violations existed. Every lint check here
now clears the cache first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#4654): anchor a suppression marker to the violation it actually trails

The matrix caught this; my own test caught it, on its first execution. The case
"two violations on one line: trailing marker suppresses only the one it trails"
expected 1 error and got 0 — both were suppressed.

ROOT CAUSE: the anchoring accepted any Line comment on the node's line whose
range started at or after the node's end. A trailing marker at the END of a line
sits after EVERY node on that line, so that condition held for all of them.
"After the node" does not identify WHICH node the marker trails. The fix reads
as correct and is not.

FIX: deferred reporting. Violations accumulate during traversal instead of being
reported immediately; at Program:exit each marker claims exactly ONE pending
violation — the one on its line whose end is nearest before the marker begins —
and every unclaimed violation is then counted and reported. One marker, one
suppression. An earlier violation sharing the line is still reported, which is
the property the security review asked for and the previous attempt only
appeared to deliver.

The counter now increments at flush time rather than during traversal, so a
suppressed occurrence still does not keep an allowlist entry alive.

AND A TOOL THAT SHOULD HAVE EXISTED BEFORE THE FIRST MATRIX RUN. `node --test`
is hard-blocked here, so this rule's test file could only ever be executed on
the remote matrix — which is why a broken anchoring shipped into a run. ESLint's
programmatic Linter API is not a test runner, and exercising the rule through it
verifies every case locally in seconds. All 24 now pass locally, including the
two-on-one-line case that failed remotely. That loop should have been built
before the rule was first sent to the matrix rather than after it failed twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#4654): backfill PR 4674 into the changeset and complete 70-docs.json

The phase gate requires enablementSequence and the Diataxis quadrants; 70-docs
now carries both, with the how-to quadrant skipped for a stated reason rather
than an empty field. The audience for this deliverable is a contributor who
trips the rule, and the task-oriented guidance reaches them in the ESLint
message itself — which names the correct predicate, says how to choose between
the realpath and lexical families, cites the Phase 3 regression caused by
choosing wrong, and gives the marker syntax. A docs/how-to page would be a
second, driftable copy read by nobody at the moment of failure.

enablementSequence is recorded as what it actually is: a VERIFICATION sequence,
not an enablement one. The rule is never off, so there is no off-to-on
transition to describe.

scripts/lint-docs-required.cjs now passes (ok_docs_updated) — it could not
evaluate against the mandated pr:0 placeholder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-09-12 22:17:46 -04:00
committed by GitHub
parent 6edd506cc7
commit bbdf7e8e84
35 changed files with 1214 additions and 114 deletions

View File

@@ -22,6 +22,7 @@
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { tryWithinRootLexical } from './security.cjs';
/* eslint-disable @typescript-eslint/no-require-imports */
const sourceMod = require('./capability-source.cjs') as {
@@ -371,7 +372,10 @@ function safeRmUnder(runtimeDir: string, rel: string): boolean {
const target = path.resolve(realRoot, rel);
let realParent: string;
try { realParent = fs.realpathSync(path.dirname(target)); } catch { return false; }
if (realParent !== realRoot && !realParent.startsWith(realRoot + path.sep)) return false;
// Containment decision is the canonical LEXICAL predicate (ADR-4650 decision 6); lexical because
// both operands are already realpath-resolved here and the final component is deliberately
// handled as a link (see above).
if (tryWithinRootLexical(realParent, realRoot) === null) return false;
const realTarget = path.join(realParent, path.basename(target));
let st: fs.Stats;
try { st = fs.lstatSync(realTarget); } catch { return true; /* already gone — idempotent */ }
@@ -405,10 +409,10 @@ function confinedSharedFile(runtimeDir: string, relFile: unknown): string | null
} catch {
// Parent does not exist yet (created inside the scope on write): a non-existent path cannot be a
// symlink escaping the root, so a lexical containment check is sufficient.
if (parentDir !== realRoot && !parentDir.startsWith(realRoot + path.sep)) return null;
if (tryWithinRootLexical(parentDir, realRoot) === null) return null;
return target;
}
if (realParent !== realRoot && !realParent.startsWith(realRoot + path.sep)) return null;
if (tryWithinRootLexical(realParent, realRoot) === null) return null;
return path.join(realParent, path.basename(target));
}
@@ -512,7 +516,7 @@ function confinedBundleScript(capDirPath: string, script: string): string | null
// disk): a non-existent root cannot be a symlink escaping itself, so confine lexically.
realCapRoot = path.resolve(capDirPath);
const targetLex = path.resolve(realCapRoot, script);
if (targetLex !== realCapRoot && !targetLex.startsWith(realCapRoot + path.sep)) return null;
if (tryWithinRootLexical(targetLex, realCapRoot) === null) return null;
return targetLex;
}
@@ -524,12 +528,12 @@ function confinedBundleScript(capDirPath: string, script: string): string | null
} catch {
// Parent does not exist yet (created inside the bundle): lexical containment is sufficient
// because a non-existent path cannot be a symlink escaping the root.
if (parentDir !== realCapRoot && !parentDir.startsWith(realCapRoot + path.sep)) return null;
if (tryWithinRootLexical(parentDir, realCapRoot) === null) return null;
return target;
}
// The realpath'd parent chain must remain inside the bundle — an ancestor symlink escaping the
// bundle is refused here (the symlink is followed by realpathSync, so its real location is checked).
if (realParent !== realCapRoot && !realParent.startsWith(realCapRoot + path.sep)) return null;
if (tryWithinRootLexical(realParent, realCapRoot) === null) return null;
return path.join(realParent, path.basename(target));
}

View File

@@ -30,7 +30,7 @@ import type { Decision } from './decisions.cjs';
import frontmatterMod = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatterMod;
import { stripFencedCode, collectSections } from './markdown-sectionizer.cjs';
import { tryWithinRoot, PathAcceptance } from './security.cjs';
import { tryWithinRoot, tryWithinRootLexical, PathAcceptance } from './security.cjs';
import { checkUiPresence } from './ui-safety-gate.cjs';
import { hasStaticFrontendEvidence } from './ui-frontend-evidence.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -415,12 +415,6 @@ function recentCommitMessages(projectDir: string): string {
}
}
function isInsideRoot(candidatePath: string, rootDir: string): boolean {
const root = path.resolve(rootDir);
const target = path.resolve(root, candidatePath);
return target === root || target.startsWith(`${root}${path.sep}`);
}
function readModifiedFilesContent(projectDir: string, summaries: string[]): string {
const out: string[] = [];
let total = 0;
@@ -431,8 +425,15 @@ function readModifiedFilesContent(projectDir: string, summaries: string[]): stri
.map((match) => match[1].trim().replace(/^["']|["']$/g, ''));
for (const file of files) {
if (total >= 50) break;
if (!file || !isInsideRoot(file, projectDir)) continue;
const raw = readIfExists(resolvePath(file, projectDir));
if (!file) continue;
// Migrated off the hand-rolled prefix check (ADR-4650): resolve+contain in one
// step via the canonical realpath predicate — the eventual read below follows
// symlinks, so containment must be decided on the resolved target, not a lexical
// prefix. Read the value the predicate RETURNED; do not re-derive the path.
const candidate = path.isAbsolute(file) ? file : path.join(projectDir, file);
const contained = tryWithinRoot(candidate, projectDir, PathAcceptance.AbsoluteInsideRoot);
if (contained === null) continue;
const raw = readIfExists(contained);
out.push(raw.length > 256 * 1024 ? raw.slice(0, 256 * 1024) : raw);
total++;
}
@@ -1494,7 +1495,14 @@ function cmdApiCoverageVerifyPre(projectDir: string, args: string[], raw: boolea
// Defense-in-depth: the resolved dir must be inside the phases root (or a
// milestone archive under .planning/milestones).
const milestonesRoot = path.join(pDir, 'milestones');
if (!isInsideRoot(resolvedDir, phasesRoot) && !isInsideRoot(resolvedDir, milestonesRoot)) {
// Lexical containment (ADR-4650): resolvedDir is a directory path, not read
// through here — mirrors the prior path.resolve(root, candidate)-based check
// without introducing a filesystem/realpath dependency this defense-in-depth
// recheck never had.
if (
tryWithinRootLexical(resolvedDir, phasesRoot) === null &&
tryWithinRootLexical(resolvedDir, milestonesRoot) === null
) {
output(
{
block: true,

View File

@@ -131,7 +131,7 @@ export function normalizeRelPath(p: unknown, repoRoot?: string): string {
if (typeof repoRoot === 'string' && repoRoot !== '') {
const rootNormalized = repoRoot.trim().replace(/\\/g, '/').replace(/\/+$/, '');
if (rootNormalized !== '' && value.startsWith(`${rootNormalized}/`)) {
if (rootNormalized !== '' && value.startsWith(`${rootNormalized}/`)) { // allow-handrolled-containment: display-path normalization — strips a caller-declared repoRoot prefix so a path renders repo-relative, not a security root-confinement decision
value = value.slice(rootNormalized.length + 1);
relativized = true;
}
@@ -160,7 +160,7 @@ export function normalizeRelPath(p: unknown, repoRoot?: string): string {
* with `rulePath + '/'`. Both arguments must already be normalized. Case-sensitive.
*/
export function ruleMatchesFile(rulePath: string, filePath: string): boolean {
return filePath === rulePath || filePath.startsWith(`${rulePath}/`);
return filePath === rulePath || filePath.startsWith(`${rulePath}/`); // allow-handrolled-containment: rule-to-file segment match for selecting which review-depth rule applies — not a filesystem root-confinement gate
}
interface RulePathValidation {

View File

@@ -11,7 +11,7 @@ import path from 'node:path';
import { normalizeEol } from './text-lines.cjs';
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir, isSpawnTimeout, retryRenameSync } from './shell-command-projection.cjs';
import { escapeRegex } from './pattern.cjs';
import { requireSafePath, sanitizeForDisplay, tryWithinRoot, PathAcceptance } from './security.cjs';
import { requireSafePath, sanitizeForDisplay, tryWithinRoot, assertWithinRoot, PathAcceptance } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output, ERROR_REASON } = ioMod;
@@ -657,13 +657,11 @@ function cmdResolveExecution(cwd: string, agentType: string | undefined, raw: bo
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
const { getGlobalConfigDir } = require('./runtime-homes.cjs') as { getGlobalConfigDir(runtime: string, explicitDir?: string | null): string };
const agentsDirEff = path.join(getGlobalConfigDir(runtime), 'agents');
const agentPath = path.join(agentsDirEff, `${agentType}.md`);
// agentType is an unvalidated CLI positional: keep the read inside the
// agents dir so `../../x` cannot point it elsewhere (defense in depth —
// the reflected surface is only a frontmatter effort line).
if (!path.resolve(agentPath).startsWith(path.resolve(agentsDirEff) + path.sep)) {
throw new Error('agent path escapes the agents directory');
}
// the reflected surface is only a frontmatter effort line). Untrusted
// input feeding a real read → realpath family (ADR-4650 decision 6).
const agentPath = assertWithinRoot(`${agentType}.md`, agentsDirEff, 'agent file');
const agentContent = fs.readFileSync(agentPath, 'utf8');
// eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the sibling frontmatter regexes in this file
const fmMatchEff = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(agentContent);
@@ -2707,7 +2705,7 @@ function groupFilesBySubrepo(files: string[], subRepos: string[]): GroupFilesByS
let matchLen = -1;
if (candidates) {
for (const repo of candidates) {
if (file.startsWith(repo + '/')) {
if (file.startsWith(repo + '/')) { // allow-handrolled-containment: sub-repo file grouping, not a safety decision
const repoLen = String(repo).length;
if (repoLen > matchLen) {
match = repo;

View File

@@ -162,7 +162,7 @@ function isActiveWorktreePath(
): boolean {
const active = shellCmdProjection.toComparablePathKey(activeCwd, platform);
const worktree = shellCmdProjection.toComparablePathKey(worktreePath, platform);
return active === worktree || active.startsWith(worktree + '/');
return active === worktree || active.startsWith(worktree + '/'); // allow-handrolled-containment: worktree identity matching, not a containment gate
}
function checkW027(snapshot: PlanningSnapshot): Diagnostic[] {

View File

@@ -23,6 +23,7 @@ import os from 'node:os';
import path from 'node:path';
import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs');
import { tryWithinRootLexical } from './security.cjs';
import runtimeArtifactLayout = require('./runtime-artifact-layout.cjs');
import runtimeArtifactInstallPlan = require('./runtime-artifact-install-plan.cjs');
import runtimeNamePolicy = require('./runtime-name-policy.cjs');
@@ -132,7 +133,7 @@ function previousOwnedCorpusFiles(configDir: string, prefix: string): string[] {
function pruneEmptyCorpusParents(start: string, stop: string): void {
let current = path.dirname(start);
while (current !== stop && current.startsWith(stop + path.sep)) {
while (current !== stop && current.startsWith(stop + path.sep)) { // allow-handrolled-containment: ancestor-walk loop condition, not a containment gate
if (installFs().readdirSync(current).length > 0) return;
installFs().rmdirSync(current);
current = path.dirname(current);
@@ -389,8 +390,11 @@ function hasExistingSymlinkBetween(
const resolvedFullPath = path.resolve(fullPath);
// (a) Path-traversal refusal — ALWAYS enforced, even with opt-in. An untrusted
// destSubpath string that escapes the install root via '..' is rejected
// regardless of user opt-in state (ADR-1239 Phase B threat (a)).
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
// regardless of user opt-in state (ADR-1239 Phase B threat (a)). Lexical
// (ADR-4650 decision 6): this function's whole purpose is to DETECT
// symlinks between root and target, so resolving them here would erase what
// it measures.
if (tryWithinRootLexical(resolvedFullPath, resolvedRoot) === null) {
return true;
}

View File

@@ -124,8 +124,16 @@ function evaluateRemoveEmptyDir(configDir: string, fullPath: string): string {
} catch {
return 'left-in-place';
}
if (resolvedTarget === resolvedRoot || !resolvedTarget.startsWith(resolvedRoot + path.sep)) {
// Refuses both "target IS configDir" and "target escaped configDir".
// `resolvedTarget === resolvedRoot` is a DELIBERATE ADDITIONAL rejection,
// separate from the containment decision: `tryWithinRootLexical` treats
// target === root as CONTAINED, but removing the config root itself is
// never in scope for this action (see the doc comment above) — this arm
// prevents `rmdirSync` from ever being asked to remove `configDir` itself.
// Kept as its own check per ADR-4650 decision 6 (a wrapper may add its own
// conditions on top of the canonical predicate, never invert it).
if (resolvedTarget === resolvedRoot) return 'left-in-place';
if (tryWithinRootLexical(resolvedTarget, resolvedRoot) === null) {
// Refuses "target escaped configDir".
return 'left-in-place';
}

View File

@@ -21,7 +21,7 @@ import { transitionCore } from './state-transition.cjs';
import { writeSetComplete } from './write-set.cjs';
import type { WriteSet } from './write-set.cjs';
import { updateTableCell, resetQuickTaskRows, QUICK_TASKS_SECTION_ABSENT } from './markdown-table.cjs';
import { requireSafePath, PathAcceptance } from './security.cjs';
import { requireSafePath, PathAcceptance, type ContainedPath } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- audit.cjs is an export= CommonJS module
import auditMod = require('./audit.cjs');
const { resolveQuickTaskSummaryFile } = auditMod;
@@ -917,7 +917,7 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
// never disagree with what a real run actually archives. Absent
// --archive-quick this stays `[]` and nothing on disk is touched either
// way (dry-run always returns before any mutation below).
const quickDirsToArchive: string[] = options.archiveQuick ? listQuickTaskDirsForArchive(cwd) : [];
const quickDirsToArchive: string[] = options.archiveQuick ? listQuickTaskDirsForArchive(cwd).map((d) => d.name) : [];
const dryRunResult = {
dry_run: true,
version,
@@ -1625,8 +1625,18 @@ function writeQuickArchiveReadme(archiveQuickDir: string): void {
* written three times, and only the real-run copy applied `requireSafePath`,
* so a dry-run preview could list a directory the real run would silently
* skip).
*
* Returns the proven `ContainedPath` alongside each entry's bare `name`
* (`no-unconfined-path-join`'s `discardedContainmentResult` arm — a bare
* statement call to `requireSafePath` throws away the exact answer it just
* computed). The two dry-run previews only need `name` for display;
* `archiveQuickTaskDirectories` deliberately does NOT reuse `abs` for its
* rename — it re-derives and re-validates independently as TOCTOU
* defense-in-depth (see its own comment), so `abs` exists here only to make
* this function's own discard explicit, not to be trusted downstream as a
* stale-safe proof.
*/
function listQuickTaskDirsForArchive(cwd: string): string[] {
function listQuickTaskDirsForArchive(cwd: string): Array<{ name: string; abs: ContainedPath }> {
const planningBase = planningPaths(cwd).planning;
const quickDir = planningPaths(cwd).quick;
let sourceEntries: fs.Dirent[];
@@ -1636,17 +1646,18 @@ function listQuickTaskDirsForArchive(cwd: string): string[] {
// .planning/quick absent or unreadable — nothing to select.
return [];
}
const names: string[] = [];
const results: Array<{ name: string; abs: ContainedPath }> = [];
for (const entry of sourceEntries) {
if (!entry.isDirectory()) continue; // excludes symlinks too — see MAJOR 3 note above
let abs: ContainedPath;
try {
requireSafePath(path.join(quickDir, entry.name), planningBase, 'quick task dir', PathAcceptance.AbsoluteInsideRoot);
abs = requireSafePath(path.join(quickDir, entry.name), planningBase, 'quick task dir', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue; // symlink/escape attempt — never a candidate, in preview OR real run
}
names.push(entry.name);
results.push({ name: entry.name, abs });
}
return names.sort();
return results.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
}
/**
@@ -1697,7 +1708,7 @@ function archiveQuickTaskDirectories(cwd: string, version: string): { archiveDir
// #2142 MAJOR 5 (review): dirNames is the SAME selection
// `listQuickTaskDirsForArchive` hands to both dry-run previews — this is
// the real run, so it cannot disagree with what a preview reported.
const dirNames = listQuickTaskDirsForArchive(cwd);
const dirNames = listQuickTaskDirsForArchive(cwd).map((d) => d.name);
if (dirNames.length === 0) {
// Boundary 0 (#2142): zero (safe) directory entries (empty dir, only
// stray files, or every entry excluded by the selection rule) must not
@@ -1820,7 +1831,7 @@ function cmdQuickArchive(cwd: string, version: string, options: QuickArchiveOpti
// `cmdMilestoneComplete`'s own dry-run preview and the real
// `archiveQuickTaskDirectories` both use, so all three can never disagree.
if (options.dryRun) {
const quickDirsToArchive: string[] = listQuickTaskDirsForArchive(cwd);
const quickDirsToArchive: string[] = listQuickTaskDirsForArchive(cwd).map((d) => d.name);
output(
{
dry_run: true,

View File

@@ -94,7 +94,7 @@ import coreUtilsMod = require('./core-utils.cjs');
const { normalizeLineEndings } = coreUtilsMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import securityMod = require('./security.cjs');
const { tryWithinRoot, PathAcceptance } = securityMod;
const { tryWithinRoot, PathAcceptance, isContainedIn } = securityMod;
/**
* The wire schema version. A consumer MUST reject any value other than this
@@ -217,15 +217,14 @@ function toPosix(value: string): string {
* (and its own not-found/broken-symlink handling).
*
* NOT an independent containment implementation — it is the comparison step
* of one. `readDocument` below realpaths target and root itself (to keep its
* own exists-vs-escaped tri-state) and calls this directly; `isPathContained`
* gets its containment DECISION from the canonical `tryWithinRoot` predicate
* instead (ADR-4650 decision 6) and no longer uses this function. Every
* of one, and that comparison now comes from `security.cts`'s exported
* `isContainedIn` rather than being redeclared here. `readDocument` below
* realpaths target and root itself (to keep its own exists-vs-escaped
* tri-state) and calls `isContainedIn` directly; `isPathContained` gets its
* containment DECISION from the canonical `tryWithinRoot` predicate instead
* (ADR-4650 decision 6) and never called this comparison directly. Every
* caller owns its own resolution.
*/
function isWithinRoot(resolvedTarget: string, resolvedRoot: string): boolean {
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep);
}
/**
* Containment check for a path (file OR directory), used where the caller
@@ -234,7 +233,7 @@ function isWithinRoot(resolvedTarget: string, resolvedRoot: string): boolean {
* call site that uses this (an escaped or unresolvable phase directory is
* treated identically to an unreadable one). `readDocument` below needs
* that distinction for its own exists/readable tri-state, so it keeps its
* own inline `realpathSync` calls and calls `isWithinRoot` directly instead
* own inline `realpathSync` calls and calls `isContainedIn` directly instead
* of this wrapper.
*
* The containment DECISION comes from the canonical `tryWithinRoot`
@@ -277,7 +276,7 @@ function readDocument(filePath: string, root: string): { text: string | null; ex
// here — the same non-answer `readDocument` already gives "not exists".
return { text: null, exists: false, readable: false };
}
if (!isWithinRoot(realTarget, realRoot)) {
if (!isContainedIn(realTarget, realRoot)) {
return { text: null, exists: true, readable: false };
}

View File

@@ -33,6 +33,7 @@
import path from 'node:path';
import fs from 'node:fs';
import { retryRenameSync } from './shell-command-projection.cjs';
import { tryWithinRootLexical } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
@@ -240,8 +241,11 @@ function resolvePhaseDirForArg(cwd: string, phaseArg: string): ResolvedPhase | n
*/
function resolveConfinedPath(cwd: string, relFile: string): string | null {
const root = path.resolve(cwd);
const resolved = path.resolve(root, relFile);
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
// ADR-4650 decision 6: lexical family — resolving the symlink here would
// undo the refuse-don't-resolve posture documented above; `lstatSync` below
// is the gate that actually refuses a symlink.
const resolved = tryWithinRootLexical(relFile, root);
if (resolved === null) return null;
try {
if (!fs.lstatSync(resolved).isFile()) return null;
} catch {

View File

@@ -12,6 +12,10 @@ import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { platformWriteSync } from './shell-command-projection.cjs';
// ADR-4650 decision 6: lexical family — the key is validated before
// `fs.mkdirSync` creates the store dir, so the candidate legitimately does
// not exist yet at check time.
import { tryWithinRootLexical } from './security.cjs';
// ---------------------------------------------------------------------------
// Constants
@@ -160,16 +164,13 @@ function putResearch(
const entry: ResearchEntry = { content, source, provider, confidence, fetched_at, ttl, kind };
const dir = resolveStorePath(cwd, source, { homeDir });
// Belt-and-suspenders: ensure the resolved file path stays inside the store dir.
const resolvedDir = path.resolve(dir);
const filePath = path.join(dir, `${key}.json`);
const resolvedFile = path.resolve(filePath);
if (!resolvedFile.startsWith(resolvedDir + path.sep)) {
const containedFile = tryWithinRootLexical(`${key}.json`, dir);
if (containedFile === null || containedFile === path.resolve(dir)) {
throw new Error('invalid research key');
}
fs.mkdirSync(dir, { recursive: true });
platformWriteSync(filePath, JSON.stringify(entry));
platformWriteSync(containedFile, JSON.stringify(entry));
return entry;
}
@@ -198,16 +199,14 @@ function getResearch(cwd: string, key: string, { clock = Date, homeDir = os.home
const candidates: Candidate[] = [];
for (const dir of tierDirs) {
const resolvedDir = path.resolve(dir);
const filePath = path.join(dir, `${key}.json`);
// Belt-and-suspenders: ensure path stays inside tier dir
if (!path.resolve(filePath).startsWith(resolvedDir + path.sep)) continue;
const containedFile = tryWithinRootLexical(`${key}.json`, dir);
if (containedFile === null || containedFile === path.resolve(dir)) continue;
if (!fs.existsSync(filePath)) continue;
if (!fs.existsSync(containedFile)) continue;
let entry: ResearchEntry;
try {
entry = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ResearchEntry;
entry = JSON.parse(fs.readFileSync(containedFile, 'utf8')) as ResearchEntry;
} catch {
// Corrupt file in this tier — skip it
continue;

View File

@@ -36,6 +36,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { estimateTokens } from './prompt-budget.cjs';
import { tryWithinRootLexical } from './security.cjs';
import type { LanePlan, ResolveResult } from './review-lane-invocation.cjs';
import { resolveLaneBudget, artifactPaths } from './review-lane-invocation.cjs';
import type { ReviewerLane } from './review-lane-descriptor.cjs';
@@ -190,8 +191,13 @@ function validatePaths(
if (typeof p !== 'string' || p.length === 0 || CONTROL_CHAR.test(p)) {
return { ok: false, reason: DISPATCH_REASON.INVALID_PATHS };
}
// ADR-4650 decision 6: lexical family — this is the first of the two
// deliberate halves (#4209 WR-05); the ENOENT-tolerant realpath half
// below cannot be folded into a single `tryWithinRoot` call (its
// ancestor-walk would accept a deleted path via the nearest existing
// ancestor, not the explicit `continue` this code requires).
const resolved = path.resolve(root, p);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
if (tryWithinRootLexical(p, root) === null) {
return { ok: false, reason: DISPATCH_REASON.PATH_ESCAPES_REPO_ROOT };
}
// #4209 WR-05: `path.resolve` is lexical only — a symlink whose OWN path sits inside
@@ -205,7 +211,7 @@ function validatePaths(
} catch {
continue;
}
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
if (tryWithinRootLexical(real, realRoot) === null) {
return { ok: false, reason: DISPATCH_REASON.PATH_ESCAPES_REPO_ROOT };
}
}

View File

@@ -11,6 +11,7 @@
// In .cts (CommonJS output) files, `require` is available as a global.
const _require: NodeRequire = require;
const path = _require('node:path') as typeof import('node:path');
const { tryWithinRootLexical } = _require('./security.cjs') as typeof import('./security.cjs');
// #2870: InstallScope is owned by install-scope.cts, not re-declared here.
// `isGlobalScope` centralizes the `scope === 'global'` boolean projection
@@ -140,13 +141,21 @@ function assertDestWithinConfigHome(configDir: string, destSubpath: string): str
);
}
const root = path.resolve(configDir);
const resolved = path.resolve(configDir, destSubpath);
if (resolved === root || !resolved.startsWith(root + path.sep)) {
// `resolved === root` is a DELIBERATE ADDITIONAL rejection, separate from
// the containment decision: `tryWithinRootLexical` treats target === root
// as CONTAINED, but a destSubpath of "" (or one that resolves to configDir
// itself) must never be accepted here — this is the strict-subpath
// requirement Phase B of ADR-1239 imposes on third-party descriptors, and
// it prevents a descriptor from writing at configHome itself. Kept as its
// own check per ADR-4650 decision 6 (a wrapper may add its own conditions
// on top of the canonical predicate, never invert it).
const contained = tryWithinRootLexical(destSubpath, configDir);
if (contained === null || contained === root) {
throw new Error(
`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`,
);
}
return resolved;
return contained;
}
function errorMessage(err: unknown): string {

View File

@@ -23,6 +23,7 @@ import os from 'node:os';
// unless the top-level installRuntimeArtifacts call injected a `deps.fs`.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installFsAdapter = require('./install-fs-adapter.cjs');
import { tryWithinRootLexical } from './security.cjs';
const { installFs, mkInstallTempDir } = installFsAdapter;
// Reuse the install manifest's existing parser and streamed SHA-256
// classification instead of deriving a second integrity implementation here.
@@ -196,10 +197,15 @@ function isReadableDirectory(candidate: string, routed: boolean): boolean {
}
function isPhysicallyConfinedTo(root: string, candidate: string): boolean {
// ADR-4650 decision 6: lexical family on already-realpath'd operands — the
// surrounding try/catch must survive verbatim, since a non-existent
// candidate throwing out of realpathSync (not `tryWithinRootLexical`, which
// would accept it) is exactly the "incomplete manifest" signal this
// function's callers depend on.
try {
const physicalRoot = installFs().realpathSync(root);
const physicalCandidate = installFs().realpathSync(candidate);
return physicalCandidate === physicalRoot || physicalCandidate.startsWith(physicalRoot + path.sep);
return tryWithinRootLexical(physicalCandidate, physicalRoot) !== null;
} catch {
return false;
}
@@ -253,9 +259,11 @@ function installedManifestIsComplete(
for (const key of expected) {
const parts = key.split('/');
if (parts.some((part) => part === '' || part === '.' || part === '..')) return false;
const candidate = path.resolve(runtimeConfigDir, ...parts);
const root = path.resolve(runtimeConfigDir);
if (!candidate.startsWith(root + path.sep)) return false;
// ADR-4650 decision 6: lexical family — the object is lstat'd (never
// stat'd) and refused if it is a symlink just below, so this gate must
// refuse rather than resolve.
const candidate = tryWithinRootLexical(parts.join('/'), runtimeConfigDir);
if (candidate === null || candidate === path.resolve(runtimeConfigDir)) return false;
const stat = io.lstatSync(candidate);
if (!stat.isFile() || stat.isSymbolicLink()) return false;
if (installerMigrations.classifyArtifact(runtimeConfigDir, key, manifest).classification !== 'managed-pristine') {
@@ -308,7 +316,7 @@ function providersShareRequiredRoots(
const overlap = (leftPath: string, rightPath: string): boolean => {
const relative = path.relative(leftPath, rightPath);
return relative === '' ||
(relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
(relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); // allow-handrolled-containment: bidirectional physical-root overlap/identity check between two providers for dedup detection — not a security confinement gate on untrusted input
};
const physicalLeft = canonicalize(leftFs, leftRoot);
const physicalRight = canonicalize(rightFs, rightRoot);

View File

@@ -35,14 +35,22 @@ import path from 'node:path';
*
* `pathImpl` lets a caller supply `path.win32` / `path.posix` instead of the
* ambient module, so win32 separator semantics are testable off Windows.
*
* Exported for callers that have ALREADY resolved both operands themselves
* and need only this comparison step (e.g. a caller that owns its own
* `fs.realpathSync` calls to preserve an exists-vs-escaped tri-state). A
* caller that has NOT resolved its operands must NOT reach for this function
* directly — the comparison alone is not a containment check — and should use
* `assertWithinRoot` / `tryWithinRoot` (or the `assertWithinRootLexical` /
* `tryWithinRootLexical` pair) instead.
*/
function isContainedIn(
export function isContainedIn(
resolvedTarget: string,
resolvedRoot: string,
pathImpl: { sep: string } = path,
): boolean {
if (resolvedTarget === resolvedRoot) return true;
return (resolvedTarget + pathImpl.sep).startsWith(resolvedRoot + pathImpl.sep);
return (resolvedTarget + pathImpl.sep).startsWith(resolvedRoot + pathImpl.sep); // allow-handrolled-containment: this IS the canonical comparison every other site routes through
}
/**

View File

@@ -8,6 +8,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { tryWithinRootLexical } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output, error, ERROR_REASON } = ioMod;
@@ -164,12 +165,14 @@ function routeResolveContent(
}
const projectRoot = path.resolve(cwd || process.cwd());
const resolvedPlanPath = path.resolve(projectRoot, plan);
const rel = path.relative(projectRoot, resolvedPlanPath);
if (rel === '..' || rel.startsWith(`..${path.sep}`)) {
// Lexical containment (ADR-4650): this path is validated before existence is
// checked below, so realpath resolution is neither available nor required.
const contained = tryWithinRootLexical(plan, projectRoot);
if (contained === null) {
error(`Plan file is outside project scope: ${plan}`, ERROR_REASON.USAGE);
return;
}
const resolvedPlanPath = contained;
if (!fs.existsSync(resolvedPlanPath)) {
error(`Plan file not found: ${plan}`, ERROR_REASON.USAGE);
return;
@@ -238,11 +241,14 @@ function routeTaskCommand({ args, cwd, raw }: RouteTaskCommandOptions): void {
} else if (args[2]) {
const projectRoot = path.resolve(cwd || process.cwd());
const requestedPath = args[2];
const resolvedTaskPath = path.resolve(projectRoot, requestedPath);
const rel = path.relative(projectRoot, resolvedTaskPath);
if (rel === '..' || rel.startsWith(`..${path.sep}`)) {
// Lexical containment (ADR-4650): validated before existence is checked below.
// `error()` here does not return/throw (preserved from before this migration),
// so resolvedTaskPath must still be computed identically on the rejected path.
const contained = tryWithinRootLexical(requestedPath, projectRoot);
if (contained === null) {
error(`Task file is outside project scope: ${requestedPath}`, ERROR_REASON.USAGE);
}
const resolvedTaskPath = contained ?? path.resolve(projectRoot, requestedPath);
if (!fs.existsSync(resolvedTaskPath)) {
error(`Task file not found: ${requestedPath}`, ERROR_REASON.USAGE);
}

View File

@@ -45,6 +45,7 @@ import coreUtilsMod = require('./core-utils.cjs');
import planningScopeMod = require('./planning-scope.cjs');
import { execGit } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { isContainedIn } from './security.cjs';
const { output, error } = io;
const { extractPhaseToken, scopeToPhase } = phaseId;
@@ -297,8 +298,11 @@ function computeCoveredDigest(projectRoot: string, coveredFiles: readonly string
// confinement check above is not enough. realpathSync resolves the
// actual target; re-confining against realRoot closes that gap.
const real = fs.realpathSync(resolved);
const realRel = path.relative(realRoot, real);
if (realRel === '' || realRel === '..' || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) {
// Both operands are already realpath-resolved (this fn's own realpathSync calls
// above), so the shared containment comparison applies directly (ADR-4650) —
// no re-resolution through assertWithinRoot/tryWithinRoot, which would redo work
// this function already owns for its exists-vs-escaped tri-state.
if (!isContainedIn(real, realRoot)) {
return null;
}
const st = fs.statSync(real);

View File

@@ -606,7 +606,7 @@ function declaredPathCovers(declaredPaths: string[] | undefined, norm: string):
return declaredPaths.some(p => {
if (typeof p !== 'string') return false;
const dp = stripLeadingDotSlash(toSlash(p));
return dp === target || dp.startsWith(target + '/');
return dp === target || dp.startsWith(target + '/'); // allow-handrolled-containment: declared-path coverage for pending-creation detection, not containment
});
}

View File

@@ -10,6 +10,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { textEncodingError } from './validate.cjs';
import { tryWithinRootLexical } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
import planningWorkspace = require('./planning-workspace.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
@@ -173,9 +174,12 @@ function verifySummaryCore(
const firstSegment = candidate.split('/')[0] || '';
if (firstSegment.indexOf('.') > 0) return false;
// Containment guard: a `../`-bearing reference must not turn this advisory
// into a filesystem existence probe outside the project.
const resolved = path.resolve(projectRoot, candidate);
if (resolved !== projectRoot && !resolved.startsWith(projectRoot + path.sep)) return false;
// into a filesystem existence probe outside the project. Lexical (ADR-4650
// decision 6): the candidate is a string pulled from a SUMMARY document and
// by construction may not exist yet — existence is what gets probed
// downstream — and this is a pure string-heuristic filter with no other fs
// access, so a realpath call would also change its cost profile.
if (tryWithinRootLexical(candidate, projectRoot) === null) return false;
return true;
};

View File

@@ -11,6 +11,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { execGit as execGitSeam, posixNormalize, type SpawnResultOutput } from './shell-command-projection.cjs';
import { isContainedIn } from './security.cjs';
// Default timeout for worktree-related git subprocess calls.
// 10 s is generous enough for normal git operations on large repos while still
@@ -743,7 +744,7 @@ function normalizeScopePath(raw: string): string {
*/
function isSummaryArtifactRelPath(relPath: string): boolean {
const normalized = normalizeScopePath(relPath);
return normalized.startsWith(`${SUMMARY_ARTIFACT_DIR}/`)
return normalized.startsWith(`${SUMMARY_ARTIFACT_DIR}/`) // allow-handrolled-containment: artifact-type classification by a fixed known subdirectory name, not a filesystem root-confinement gate
&& normalized.endsWith(SUMMARY_ARTIFACT_SUFFIX);
}
@@ -947,7 +948,7 @@ function planWaveScopeConformance(
seen.add(changed);
if (isSummaryArtifactRelPath(changed)) continue;
const covered = prefixes.some((prefix) => (
prefix === null || changed === prefix || changed.startsWith(`${prefix}/`)
prefix === null || changed === prefix || changed.startsWith(`${prefix}/`) // allow-handrolled-containment: advisory scope-coverage match against a caller-declared prefix (documented above as deliberately distinct from a security gate) — not a filesystem root-confinement decision
));
if (covered) continue;
warnings.push({ code: WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED, branch, path: changed });
@@ -1879,11 +1880,15 @@ function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCm
{
const absRoot = path.resolve(cwd, rootFlag);
const absWorktree = path.resolve(cwd, plan.entry.worktree_path);
const rel = path.relative(absRoot, absWorktree);
// rel === '' → the worktree IS the root (would clobber the checkout)
// rel === '..' / '../…' → escapes the root
// path.isAbsolute(rel) → a different Windows drive or UNC root
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
// Lexical containment (ADR-4650): the worktree does not exist yet, so there is
// nothing to realpath. Both operands are already resolved above, so the shared
// `isContainedIn` comparison applies directly (mirrors the already-resolved-caller
// exception documented on `isContainedIn`) — a different Windows drive/UNC root
// fails the prefix comparison the same way an escaping relative path does.
// The canonical predicate treats target === root as CONTAINED; this call site
// explicitly REJECTS that case (absWorktree === absRoot below), because a worktree
// AT the root would clobber the checkout — the inversion this migration preserves.
if (absWorktree === absRoot || !isContainedIn(absWorktree, absRoot)) {
const hint = `--path must resolve INSIDE --root (root="${absRoot}", path="${absWorktree}"). A worktree outside the declared root is unreachable by manifest-scoped cleanup and would let a spawned executor write outside the project.`;
writeErr(`[gsd] worktree.create: path_outside_root — ${hint}\n`);
write(`${JSON.stringify({ ok: false, reason: 'path_outside_root', hint }, null, 2)}\n`);