refactor(#4653): replace the allowAbsolute flag with a named acceptance policy

Phase 3 of epic #4636, stage 3b. Satisfies #4653's criterion that
`opts.allowAbsolute` become "a named acceptance policy on the predicate, not a
per-call-site boolean".

The flag was actively misleading at the call site. `{ allowAbsolute: true }`
reads as "containment is relaxed here". It never was: an absolute path that
resolves outside the root is rejected exactly as a traversal is. The flag only
ever controlled whether an absolute candidate was CONSIDERED. On a security
predicate that is the wrong thing for a reviewer to have to infer, and 31 call
sites were asking them to infer it.

    PathAcceptance.RelativeOnly         relative candidates only
    PathAcceptance.AbsoluteInsideRoot   absolute accepted, containment unchanged

The three exported wrappers take the policy and translate it inward.
validatePath keeps its internal `{ allowAbsolute }` opts and its body untouched —
the engine is not re-derived here either, only the exported surface is renamed.

MEASURED, NOT ESTIMATED. 31 call sites across 10 files, counted by walking the
AST with the repo's own @typescript-eslint/parser rather than grepping: a text
match would have folded in the options-type declaration, default parameter
values and comments. All 31 pass the literal `true`; none passes `false` or a
dynamic value, so the migration is uniform and `RelativeOnly` is purely the
existing default made nameable. audit.cts alone holds 18 of them.

This migration is compiler-verified in a way the containment-value migration in
the previous commit was not: the parameter type changed from an object to a
string union, so any missed site is a build error rather than a silent
behavioral difference. That is why a 31-site mechanical edit is acceptable in
the phase whose stated risk is the width of mechanical change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sim
2026-09-12 12:31:01 -04:00
parent 26384ca988
commit 7e7a239a65
13 changed files with 85 additions and 59 deletions

View File

@@ -196,12 +196,37 @@ export function loadTrustedGlobalRoots(config: unknown): string[] {
*/
export type ContainedPath = string & { readonly __containedIn: unique symbol };
/**
* Named acceptance policy for what kind of candidate path is even considered.
*
* This replaces the old per-call-site `{ allowAbsolute: true }` boolean flag.
* At a call site, `{ allowAbsolute: true }` reads as "containment is relaxed
* here" — which is FALSE. An absolute path that resolves OUTSIDE the root is
* still rejected; the flag only ever controlled whether an absolute candidate
* was considered at all. `AbsoluteInsideRoot` states the real contract: an
* absolute candidate is accepted for consideration, but containment is
* enforced exactly as it is for a relative one.
*/
export const PathAcceptance = {
/** Relative candidates only; an absolute candidate is rejected outright. */
RelativeOnly: 'relative-only',
/**
* An absolute candidate is accepted — but ONLY if it still resolves inside the
* root. Containment is NOT relaxed by this policy; an absolute path outside the
* root is rejected exactly as a traversal is. This is the distinction the old
* `{ allowAbsolute: true }` flag failed to make at its call sites.
*/
AbsoluteInsideRoot: 'absolute-inside-root',
} as const;
export type PathAcceptancePolicy = (typeof PathAcceptance)[keyof typeof PathAcceptance];
/**
* Validate a file path and throw on traversal attempt.
* Convenience wrapper around validatePath for use in CLI commands.
*/
export function assertWithinRoot(candidate: unknown, root: unknown, label?: string | null, opts: { allowAbsolute?: boolean } = {}): ContainedPath {
const result = validatePath(candidate, root, opts);
export function assertWithinRoot(candidate: unknown, root: unknown, label?: string | null, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath {
const result = validatePath(candidate, root, { allowAbsolute: policy === PathAcceptance.AbsoluteInsideRoot });
if (!result.safe) {
throw new Error(`${label || 'Path'} validation failed: ${result.error}`);
}
@@ -216,8 +241,8 @@ export function assertWithinRoot(candidate: unknown, root: unknown, label?: stri
* traversal branch, so returning it here would reproduce the defect this
* narrowing exists to remove.
*/
export function tryWithinRoot(candidate: unknown, root: unknown, opts: { allowAbsolute?: boolean } = {}): ContainedPath | null {
const result = validatePath(candidate, root, opts);
export function tryWithinRoot(candidate: unknown, root: unknown, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath | null {
const result = validatePath(candidate, root, { allowAbsolute: policy === PathAcceptance.AbsoluteInsideRoot });
if (!result.safe) {
return null;
}
@@ -232,8 +257,8 @@ export function tryWithinRoot(candidate: unknown, root: unknown, opts: { allowAb
* names; its declared return type is ContainedPath (a branded string, still
* assignable to string) so existing callers keep compiling untouched.
*/
export function requireSafePath(filePath: unknown, baseDir: unknown, label: string | null | undefined, opts: { allowAbsolute?: boolean } = {}): ContainedPath {
return assertWithinRoot(filePath, baseDir, label, opts);
export function requireSafePath(filePath: unknown, baseDir: unknown, label: string | null | undefined, policy: PathAcceptancePolicy = PathAcceptance.RelativeOnly): ContainedPath {
return assertWithinRoot(filePath, baseDir, label, policy);
}
// ─── Prompt Injection Detection ────────────────────────────────────────────────────