refactor(#4653): make containment ONE decision, resolved two ways

Satisfies #4653 DW1 and DW9, which were the phase's outstanding acceptance
criteria: every other implementation must be deleted or route its containment
DECISION through the canonical predicate, and no surviving wrapper may decide
WHETHER a path is contained.

Three implementations were being retained with their own comparisons, on the
argument that each needs LEXICAL resolution — a realpath-based predicate is the
wrong tool wherever a symlink must be preserved rather than resolved. That
argument is correct about RESOLUTION and was being used to justify owning the
DECISION too. Those are separable, and separating them is what closes the
criteria honestly rather than by reinterpretation.

  isContainedIn(resolvedTarget, resolvedRoot, pathImpl?)   module-internal

is now the single place this repo decides containment. It is separator-aware, so
a sibling merely sharing a prefix (`<root>-evil` against `<root>`) is still
rejected. Two exported families sit on it and differ ONLY in how a candidate is
resolved before the decision:

  assertWithinRoot / tryWithinRoot                realpath-resolving
  assertWithinRootLexical / tryWithinRootLexical  path.resolve only, no I/O

The lexical pair carries `opts.pathImpl`, so win32 separator semantics stay
testable off Windows — that seam already existed in isPathConfined and would
have been lost by a naive collapse.

The three call sites now take their decision from the predicate and keep only
what is genuinely theirs:

  external-descriptor-trust isPathConfined   delegates outright; pathImpl forwarded
  installer-migrations ensureInsideConfig    delegates; keeps its own message and
                                             its LEXICAL fullPath, which callers
                                             consume for existsSync and journal rows
  gsd-tools.cjs isInsideDir                  delegates; keeps its own `target !==
                                             root` condition, and the separate
                                             symlink refusal above it stands

DW5 is not weakened by this. That criterion binds the symlink oracle and the
ancestor canonicalization; both are untouched. The only change inside
validatePath is three comparison lines becoming one call, and the rejection
string `Path escapes allowed directory: <resolved> is outside <base>` stays
byte-identical because it is an observable CLI contract.

What this does NOT do, stated plainly: the lexical family still cannot see a
symlink. That is a property of lexical resolution, not a gap in the seam, and
the three callers that need it are the three that must pair it with their own
symlink refusal — which is exactly what the fix earlier in this phase added at
the install sites. The doc comment says so at the definition, and CONTEXT.md and
docs/explanation/security-model.md are corrected: they previously described
these three as deliberately NOT routed through the predicate, which is no longer
true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sim
2026-09-12 18:33:01 -04:00
parent 6f0e5ccf85
commit bbc3f131be
6 changed files with 123 additions and 29 deletions

File diff suppressed because one or more lines are too long

View File

@@ -162,10 +162,19 @@ module is the central security utility. It provides:
- Shell argument validation: arguments passed to subshell commands are
validated before use
Three containment checks elsewhere in the tree are deliberately NOT routed through
this predicate, because each is narrower or stricter rather than a second opinion.
The common thread is that a realpath-based predicate is the wrong tool wherever a
symlink must be *preserved* rather than resolved.
Containment is decided in exactly one place, but resolved two ways. The
comparison itself — separator-aware, so a sibling merely sharing a prefix is
never accepted — is internal to `security.cjs` and is the single decision. Two
exported families sit on it and differ only in how a candidate is resolved
before that decision: `assertWithinRoot` / `tryWithinRoot` resolve symlinks,
and `assertWithinRootLexical` / `tryWithinRootLexical` use string resolution
alone and never touch the filesystem.
The lexical form exists because a realpath-based predicate is the wrong tool
wherever a symlink must be *preserved* rather than resolved, or where the target
legitimately does not exist yet. A lexical check **cannot see a symlink**, so a
caller relying on one for a write-confinement guarantee must pair it with its
own symlink refusal. Three call sites use it, each for a stated reason.
The backup-restore gate in `gsd-core/bin/gsd-tools.cjs` rejects symlinks outright:
the canonical predicate accepts a link whose target resolves inside the root, but

View File

@@ -3359,16 +3359,22 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
// at the backed-up path. These checks reject links outright, which is
// strictly stricter than assertWithinRoot/tryWithinRoot, not a
// reimplementation of them. Do not "simplify" this to assertWithinRoot or
// tryWithinRoot. Reviewed under epic #4636 Phase 3 and deliberately NOT
// collapsed. Note also: isInsideDir below treats target === root as NOT
// contained (it requires a non-empty relative path), unlike every other
// containment implementation in this repo, which treats target === root as
// contained.
// tryWithinRoot. Reviewed under epic #4636 Phase 3: the containment
// DECISION now routes through the canonical lexical predicate
// (`tryWithinRootLexical`, ADR-4650 decision 6); isInsideDir below still
// treats target === root as NOT contained via its own extra `!==` check
// (unlike every other containment implementation in this repo, which
// treats target === root as contained) — that condition is this gate's
// own and is layered on top of the shared predicate, not folded into it.
/** True when `target` resolves strictly inside `root`. */
function isInsideDir(root, target) {
const rel = path.relative(path.resolve(root), path.resolve(target));
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
// Containment decision: canonical lexical predicate (ADR-4650 decision 6).
// The extra `!==` condition is this gate's own: a restore must never
// target the config directory itself, only something strictly inside it.
if (path.resolve(target) === path.resolve(root)) return false;
const { tryWithinRootLexical } = require('./lib/security.cjs');
return tryWithinRootLexical(target, root) !== null;
}
/**

View File

@@ -20,6 +20,7 @@
'use strict';
import path from 'node:path';
import { tryWithinRootLexical } from './security.cjs';
/**
* Pure LEXICAL path-containment check (cross-platform). `target` is confined
@@ -53,6 +54,11 @@ import path from 'node:path';
* in this repo, e.g. src/shell-command-projection.cts's `opts.platform`
* (#4641). All existing 2-arg callers are unaffected: the default resolves to
* the ambient `path`, preserving byte-identical behaviour.
*
* The containment DECISION here now comes from the canonical predicate in
* src/security.cts (`tryWithinRootLexical`, ADR-4650 decision 6) — this
* function keeps only the lexical RESOLUTION policy (no realpath, no
* filesystem access) as its own choice; the comparison itself is shared.
*/
export function isPathConfined(
target: string,
@@ -62,11 +68,7 @@ export function isPathConfined(
if (typeof target !== 'string' || typeof root !== 'string' || target.length === 0 || root.length === 0) {
return false;
}
const p = opts.pathImpl ?? path;
const rootResolved = p.resolve(root);
const targetResolved = p.resolve(root, target);
const prefix = rootResolved + p.sep;
return targetResolved === rootResolved || targetResolved.startsWith(prefix);
return tryWithinRootLexical(target, root, { pathImpl: opts.pathImpl }) !== null;
}
export interface DescriptorArtifactKind {

View File

@@ -19,6 +19,7 @@ import {
import { platformWriteSync, retryRenameSync, posixNormalize } from './shell-command-projection.cjs';
import { realClock, type Clock } from './clock.cjs';
import { isInstallScopeId, type InstallScope } from './install-scope.cjs';
import { tryWithinRootLexical } from './security.cjs';
// #2874 (ADR-58 cleanup phase): this file is the ~1200-line migration
// plan/apply/rollback/lock/journal engine — almost none of it is on the
// installRuntimeArtifacts call tree. Only `readInstallManifest` and
@@ -664,26 +665,30 @@ interface EnsureInsideConfigResult {
fullPath: string;
}
// DELIBERATELY LEXICAL — do not route this through the canonical containment
// predicate (`assertWithinRoot` / `tryWithinRoot`, src/security.cts).
// DELIBERATELY LEXICAL — the RESOLUTION policy stays lexical, never realpath
// (`assertWithinRoot` / `tryWithinRoot`, src/security.cts).
//
// Reviewed under epic #4636 Phase 3 and reverted after the remote matrix proved
// the collapse wrong. This module's contract is that a symlinked managed path is
// treated AS A LINK and never dereferenced — it is snapshotted as a link,
// restored as a link, and backed up as a link. The canonical predicate
// realpath-resolves, so it dereferences exactly the symlinks this module exists
// to preserve and then rejects them for escaping configDir
// the realpath collapse wrong. This module's contract is that a symlinked
// managed path is treated AS A LINK and never dereferenced — it is snapshotted
// as a link, restored as a link, and backed up as a link. The realpath-based
// predicate dereferences exactly the symlinks this module exists to preserve
// and then rejects them for escaping configDir
// ("migration path escapes configDir: extensions/gsd.cjs"). Four tests in
// tests/installer-migrations.test.cjs pin that behavior.
//
// The containment DECISION now routes through the canonical LEXICAL predicate
// (`tryWithinRootLexical`, ADR-4650 decision 6) — only the comparison moved;
// the lexical policy itself remains this module's own required choice, and
// the thrown message / returned `fullPath` are unchanged.
//
// `normalizeRelPath` is the pre-gate: it throws on absolute paths and on any
// '..' segment BEFORE this runs, so the check below is defense-in-depth over
// already-traversal-free input rather than the primary boundary.
function ensureInsideConfig(configDir: string, relPath: string): EnsureInsideConfigResult {
const normalized = normalizeRelPath(relPath);
const fullPath = path.resolve(configDir, normalized);
const root = path.resolve(configDir);
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
if (tryWithinRootLexical(fullPath, configDir) === null) {
throw new Error(`migration path escapes configDir: ${relPath}`);
}
return { normalized, fullPath };

View File

@@ -24,6 +24,27 @@ import path from 'node:path';
// ─── Path Traversal Prevention ──────────────────────────────────────────────
/**
* THE containment comparison — the single place this repo decides whether an
* already-resolved path lies inside an already-resolved root (ADR-4650).
*
* Separator-aware on purpose: comparing the bare strings would accept a
* sibling that merely shares a prefix (`<root>-evil` against `<root>`), so both
* sides get a trailing separator before the prefix test. `target === root` is
* contained.
*
* `pathImpl` lets a caller supply `path.win32` / `path.posix` instead of the
* ambient module, so win32 separator semantics are testable off Windows.
*/
function isContainedIn(
resolvedTarget: string,
resolvedRoot: string,
pathImpl: { sep: string } = path,
): boolean {
if (resolvedTarget === resolvedRoot) return true;
return (resolvedTarget + pathImpl.sep).startsWith(resolvedRoot + pathImpl.sep);
}
/**
* Validate that a file path resolves within an allowed base directory.
* Prevents path traversal attacks via ../ sequences, symlinks, or absolute paths.
@@ -102,9 +123,7 @@ function validatePath(filePath: unknown, baseDir: unknown, opts: { allowAbsolute
}
}
}
const normalizedBase = resolvedBase + path.sep;
const normalizedPath = resolvedPath + path.sep;
if (resolvedPath !== resolvedBase && !normalizedPath.startsWith(normalizedBase)) {
if (!isContainedIn(resolvedPath, resolvedBase)) {
return {
safe: false,
resolved: resolvedPath,
@@ -261,6 +280,59 @@ export function requireSafePath(filePath: unknown, baseDir: unknown, label: stri
return assertWithinRoot(filePath, baseDir, label, policy);
}
/**
* LEXICAL containment — `path.resolve` only, never any filesystem access.
*
* Shares `isContainedIn` with the realpath-based predicate, so there is ONE
* containment decision in this repo; these differ only in how a path is
* RESOLVED before that decision, never in the decision itself (ADR-4650
* decisions 1 and 6).
*
* Use this — and say why at the call site — only where a symlink must be
* PRESERVED rather than resolved, or where the target legitimately does not
* exist yet. Three such cases exist: a destination validated before the
* `mkdirSync` that creates it, a migration that snapshots and restores a
* symlinked path AS A LINK, and a restore gate that refuses links outright.
* Everywhere else the realpath-based `assertWithinRoot` / `tryWithinRoot` is
* the correct predicate, because a lexical check CANNOT SEE A SYMLINK: a
* caller relying on one for a write-confinement guarantee must pair it with
* its own symlink refusal.
*
* `candidate` is resolved RELATIVE TO `root` (so an absolute candidate is
* taken as-is, matching `path.resolve` semantics). `target === root` is
* contained.
*
* DELIBERATELY ABSENT: no NUL-byte rejection here. The existing lexical
* callers do not reject NUL at this layer (one of them checks NUL itself,
* separately), and adding it here would change their behavior. Callers that
* need it keep their own check.
*/
export function tryWithinRootLexical(
candidate: unknown,
root: unknown,
opts: { pathImpl?: { resolve(...segments: string[]): string; sep: string } } = {},
): ContainedPath | null {
const p = opts.pathImpl || path;
if (typeof candidate !== 'string' || candidate === '') return null;
if (typeof root !== 'string' || root === '') return null;
const rootResolved = p.resolve(root);
const targetResolved = p.resolve(root, candidate);
return isContainedIn(targetResolved, rootResolved, p) ? (targetResolved as ContainedPath) : null;
}
export function assertWithinRootLexical(
candidate: unknown,
root: unknown,
label?: string | null,
opts: { pathImpl?: { resolve(...segments: string[]): string; sep: string } } = {},
): ContainedPath {
const contained = tryWithinRootLexical(candidate, root, opts);
if (contained === null) {
throw new Error(`${label || 'Path'} validation failed: lexical containment check failed`);
}
return contained;
}
// ─── Prompt Injection Detection ────────────────────────────────────────────────────
/**