* 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:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user