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

@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { requireSafePath } from './security.cjs';
import { requireSafePath, PathAcceptance } from './security.cjs';
import { collectSections } from './markdown-sectionizer.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cliExitModule = require('./cli-exit.cjs');
@@ -458,7 +458,7 @@ function parseCliArgs(argv: string[]): CliOpts {
function main(argv: string[]): void {
const opts = parseCliArgs(argv);
const safePath = requireSafePath(opts.input, path.resolve(opts.projectDir), 'ADR input path', { allowAbsolute: true });
const safePath = requireSafePath(opts.input, path.resolve(opts.projectDir), 'ADR input path', PathAcceptance.AbsoluteInsideRoot);
const content = fs.readFileSync(safePath, 'utf8');
const parsed = parseAdrMarkdown(content, { sourcePath: opts.input ?? undefined, format: opts.format });
process.stdout.write(JSON.stringify(parsed, null, 2));

View File

@@ -32,7 +32,7 @@ const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocator = require('./phase-locator.cjs');
const { getAllArchivedPhaseDirs } = phaseLocator;
import { requireSafePath, sanitizeForDisplay, sanitizeLabel } from './security.cjs';
import { requireSafePath, sanitizeForDisplay, sanitizeLabel, PathAcceptance } from './security.cjs';
import { platformWriteSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import io = require('./io.cjs');
@@ -447,7 +447,7 @@ function scanDebugSessions(planDir: string): ScanOutcome<DebugSessionItem> {
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true });
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -563,7 +563,7 @@ function scanQuickTasks(planDir: string): ScanOutcome<QuickTaskItem> {
let safeTaskDir: string;
try {
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -577,7 +577,7 @@ function scanQuickTasks(planDir: string): ScanOutcome<QuickTaskItem> {
if (summaryPath && fs.existsSync(summaryPath)) {
let safeSum: string;
try {
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -658,7 +658,7 @@ function scanThreads(planDir: string): ScanOutcome<ThreadItem> {
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true });
safeFilePath = requireSafePath(filePath, planDir, 'thread file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -748,7 +748,7 @@ function scanTodos(todosBase: string): ScanOutcome<TodoItem> {
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, todosBase, 'todo file', { allowAbsolute: true });
safeFilePath = requireSafePath(filePath, todosBase, 'todo file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -829,7 +829,7 @@ function scanSeeds(planDir: string): ScanOutcome<SeedItem> {
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true });
safeFilePath = requireSafePath(filePath, planDir, 'seed file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -1014,7 +1014,7 @@ function scanUatGaps(planDir: string, cwd: string): ScanOutcome<UatGapItem> {
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -1097,7 +1097,7 @@ function scanVerificationGaps(planDir: string, cwd: string): ScanOutcome<Verific
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -1166,7 +1166,7 @@ function scanContextQuestions(planDir: string, cwd: string): ScanOutcome<Context
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -1262,7 +1262,7 @@ function scanDeferredItems(planDir: string, cwd: string): ScanOutcome<DeferredIt
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'deferred items file', { allowAbsolute: true });
safeFilePath = requireSafePath(filePath, planDir, 'deferred items file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -1667,7 +1667,7 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
ioError(`no phase directory found for phase "${phase as string}"${archivedMilestone ? ` (archived-milestone "${archivedMilestone}")` : ''}`);
}
const filePath = path.join(targetDir as string, file as string);
const safeFilePath = requireSafePath(filePath, planDir, 'audit acknowledge target', { allowAbsolute: true });
const safeFilePath = requireSafePath(filePath, planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
if (!fs.existsSync(safeFilePath)) ioError(`file not found: ${file as string}`);
if (category === 'deferred_items') {
@@ -1745,19 +1745,19 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
if (category === 'debug_sessions') {
if (!slug) ioError('--slug is required for --category debug_sessions');
safeFilePath = requireSafePath(path.join(planDir, 'debug', `${slug as string}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
safeFilePath = requireSafePath(path.join(planDir, 'debug', `${slug as string}.md`), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
if (!fs.existsSync(safeFilePath)) ioError(`file not found: debug/${slug as string}.md`);
const content = fs.readFileSync(safeFilePath, 'utf-8');
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'unknown').toLowerCase();
} else if (category === 'threads') {
if (!slug) ioError('--slug is required for --category threads');
safeFilePath = requireSafePath(path.join(planDir, 'threads', `${slug as string}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
safeFilePath = requireSafePath(path.join(planDir, 'threads', `${slug as string}.md`), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
if (!fs.existsSync(safeFilePath)) ioError(`file not found: threads/${slug as string}.md`);
const content = fs.readFileSync(safeFilePath, 'utf-8');
currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
} else if (category === 'seeds') {
if (!seedId) ioError('--seed-id is required for --category seeds');
safeFilePath = requireSafePath(path.join(planDir, 'seeds', `${seedId as string}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
safeFilePath = requireSafePath(path.join(planDir, 'seeds', `${seedId as string}.md`), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
if (!fs.existsSync(safeFilePath)) ioError(`file not found: seeds/${seedId as string}.md`);
const content = fs.readFileSync(safeFilePath, 'utf-8');
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'dormant').toLowerCase();
@@ -1768,18 +1768,18 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
// old workstream-scoped planDir boundary would refuse a root todos file
// outright, and even a path fix alone would have thrown here.
const rootTodos = todosDir(cwd);
safeFilePath = requireSafePath(path.join(rootTodos, 'pending', filename as string), rootTodos, 'audit acknowledge target', { allowAbsolute: true });
safeFilePath = requireSafePath(path.join(rootTodos, 'pending', filename as string), rootTodos, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
if (!fs.existsSync(safeFilePath)) ioError(`file not found: todos/pending/${filename as string}`);
currentValue = ''; // presence-only — see scanTodos
} else if (category === 'quick_tasks') {
if (!quickDir) ioError('--dir is required for --category quick_tasks');
const taskDir = requireSafePath(path.join(planDir, 'quick', quickDir as string), planDir, 'audit acknowledge target dir', { allowAbsolute: true });
const taskDir = requireSafePath(path.join(planDir, 'quick', quickDir as string), planDir, 'audit acknowledge target dir', PathAcceptance.AbsoluteInsideRoot);
if (!fs.existsSync(taskDir)) ioError(`directory not found: quick/${quickDir as string}`);
// Shared with scanQuickTasks (#3458 follow-up) so the reader and this
// writer can never disagree about which file is the task's record.
const resolvedSummaryPath = resolveQuickTaskSummaryFile(taskDir, quickDir as string);
if (resolvedSummaryPath) {
safeFilePath = requireSafePath(resolvedSummaryPath, planDir, 'audit acknowledge target', { allowAbsolute: true });
safeFilePath = requireSafePath(resolvedSummaryPath, planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
const content = fs.readFileSync(safeFilePath, 'utf-8');
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'unknown').toLowerCase();
} else {
@@ -1789,7 +1789,7 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
// acknowledgment's own snapshot of "no summary exists yet", which
// self-invalidates the moment a real SUMMARY.md is written (the
// scanner then reads THAT file's own status instead).
safeFilePath = requireSafePath(path.join(taskDir, `${quickDir as string}-SUMMARY.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
safeFilePath = requireSafePath(path.join(taskDir, `${quickDir as string}-SUMMARY.md`), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
currentValue = 'missing';
createIfMissing = true;
fmForCreate = { status: 'missing' };

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 } from './security.cjs';
import { tryWithinRoot, 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
@@ -97,7 +97,7 @@ function readIfExists(filePath: string): string {
function resolvePath(inputPath: string, projectDir: string): string {
const candidate = path.isAbsolute(inputPath) ? inputPath : path.join(projectDir, inputPath);
const contained = tryWithinRoot(candidate, projectDir, { allowAbsolute: true });
const contained = tryWithinRoot(candidate, projectDir, PathAcceptance.AbsoluteInsideRoot);
if (contained === null) {
error(`path escapes its allowed directory: ${inputPath}`, ERROR_REASON.USAGE);
}

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 } from './security.cjs';
import { requireSafePath, sanitizeForDisplay, tryWithinRoot, PathAcceptance } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
const { output, ERROR_REASON } = ioMod;
@@ -352,7 +352,7 @@ function cmdListSeeds(cwd: string, statusFilter: string | undefined, raw: boolea
let safeFilePath: string;
try {
safeFilePath = requireSafePath(path.join(seedsDir, entry.name), planDir, 'seed file', { allowAbsolute: true });
safeFilePath = requireSafePath(path.join(seedsDir, entry.name), planDir, 'seed file', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue;
}
@@ -3517,11 +3517,11 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod
const sourcePath = path.join(pendingDir, filename);
const targetPath = path.join(completedDir, filename);
const sourceContained = tryWithinRoot(sourcePath, todosRoot, { allowAbsolute: true });
const sourceContained = tryWithinRoot(sourcePath, todosRoot, PathAcceptance.AbsoluteInsideRoot);
if (sourceContained === null) {
error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
}
const targetContained = tryWithinRoot(targetPath, todosRoot, { allowAbsolute: true });
const targetContained = tryWithinRoot(targetPath, todosRoot, PathAcceptance.AbsoluteInsideRoot);
if (targetContained === null) {
error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
}

View File

@@ -30,7 +30,7 @@ const { output, error } = io;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtils = require('./core-utils.cjs');
const { toPosixPath } = coreUtils;
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
import { requireSafePath, sanitizeForDisplay, PathAcceptance } from './security.cjs';
// ─── Frozen typed-IR surface ────────────────────────────────────────────────
@@ -475,7 +475,7 @@ function cmdClassify(cwd: string, options: { summary?: string; file?: string } =
let resolvedPath: string;
try {
resolvedPath = requireSafePath(filePath, cwd, 'SUMMARY file', { allowAbsolute: true });
resolvedPath = requireSafePath(filePath, cwd, 'SUMMARY file', PathAcceptance.AbsoluteInsideRoot);
} catch (e) {
// Emit a structured command error instead of leaking a raw stack trace.
error(`Invalid SUMMARY path: ${e instanceof Error ? e.message : 'unsafe path'}`);

View File

@@ -44,7 +44,7 @@ import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
import { resolveReportedRuntime } from './host-runtime-detection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
import commandsMod = require('./commands.cjs');
import { tryWithinRoot, loadTrustedGlobalRoots } from './security.cjs';
import { tryWithinRoot, loadTrustedGlobalRoots, PathAcceptance } from './security.cjs';
import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase, getGlobalConfigDir } from './runtime-homes.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
@@ -4015,10 +4015,10 @@ function buildAgentSkillsBlock(
);
continue;
}
const globalSkillMdContained = tryWithinRoot(globalSkillMd, globalSkillsBase, { allowAbsolute: true });
const globalSkillMdContained = tryWithinRoot(globalSkillMd, globalSkillsBase, PathAcceptance.AbsoluteInsideRoot);
if (globalSkillMdContained === null) {
const acceptedViaTrustedRoot = trustedGlobalRoots.some((root) => {
return tryWithinRoot(globalSkillMd, root, { allowAbsolute: true }) !== null;
return tryWithinRoot(globalSkillMd, root, PathAcceptance.AbsoluteInsideRoot) !== null;
});
if (!acceptedViaTrustedRoot) {
warn(

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 } from './security.cjs';
import { requireSafePath, PathAcceptance } 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;
@@ -1640,7 +1640,7 @@ function listQuickTaskDirsForArchive(cwd: string): string[] {
for (const entry of sourceEntries) {
if (!entry.isDirectory()) continue; // excludes symlinks too — see MAJOR 3 note above
try {
requireSafePath(path.join(quickDir, entry.name), planningBase, 'quick task dir', { allowAbsolute: true });
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
}
@@ -1719,7 +1719,7 @@ function archiveQuickTaskDirectories(cwd: string, version: string): { archiveDir
// rename are two separate filesystem observations, and an entry
// that was a safe real directory at selection time could in theory
// be swapped for a symlink before this loop reaches it.
safeSrc = requireSafePath(src, planningBase, 'quick task dir', { allowAbsolute: true });
safeSrc = requireSafePath(src, planningBase, 'quick task dir', PathAcceptance.AbsoluteInsideRoot);
} catch {
continue; // symlink/escape attempt — skip, not archived
}

View File

@@ -34,7 +34,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { requireSafePath, safeJsonParse } from './security.cjs';
import { requireSafePath, safeJsonParse, PathAcceptance } from './security.cjs';
import {
appendQuickTaskRow,
parseMarkdownTable,
@@ -305,7 +305,7 @@ function parseTaskListFromFile(cwd: string, filePath: string): Result<QuickBatch
const root = planningRoot(cwd);
let safePath: string;
try {
safePath = requireSafePath(filePath, root, 'quick-batch --file', { allowAbsolute: true });
safePath = requireSafePath(filePath, root, 'quick-batch --file', PathAcceptance.AbsoluteInsideRoot);
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
}

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 ────────────────────────────────────────────────────

View File

@@ -620,8 +620,8 @@ function readTextArgOrFile(cwd: string, value: string | undefined, filePath: str
// Path traversal guard: ensure file resolves within project directory
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
const { assertWithinRoot } = require('./security.cjs') as { assertWithinRoot(filePath: unknown, baseDir: unknown, label?: string | null, opts?: { allowAbsolute?: boolean }): string };
const contained = assertWithinRoot(filePath, cwd, `${label} path`, { allowAbsolute: true });
const { assertWithinRoot, PathAcceptance } = require('./security.cjs') as { assertWithinRoot(filePath: unknown, baseDir: unknown, label?: string | null, policy?: 'relative-only' | 'absolute-inside-root'): string; PathAcceptance: { RelativeOnly: 'relative-only'; AbsoluteInsideRoot: 'absolute-inside-root' } };
const contained = assertWithinRoot(filePath, cwd, `${label} path`, PathAcceptance.AbsoluteInsideRoot);
try {
return fs.readFileSync(contained, 'utf-8').trimEnd();

View File

@@ -38,7 +38,7 @@ const { listMilestonePhaseDirs, getAllArchivedPhaseDirs } = phaseLocator;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import auditMod = require('./audit.cjs');
const { isAuditItemAcknowledged, deriveUatGapSnapshotValue } = auditMod;
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
import { requireSafePath, sanitizeForDisplay, PathAcceptance } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
import configLoader = require('./config-loader.cjs');
const { loadConfig } = configLoader;
@@ -403,7 +403,7 @@ function cmdRenderCheckpoint(cwd: string, options: { file?: string } = {}, raw:
error('UAT file required: use uat render-checkpoint --file <path>');
}
const resolvedPath = requireSafePath(filePath, cwd, 'UAT file', { allowAbsolute: true });
const resolvedPath = requireSafePath(filePath, cwd, 'UAT file', PathAcceptance.AbsoluteInsideRoot);
if (!fs.existsSync(resolvedPath)) {
error(`UAT file not found: ${filePath}`);
}

View File

@@ -35,7 +35,7 @@ function runGsdToolsWithStderr(args, cwd, env) {
};
}
const { loadTrustedGlobalRoots, tryWithinRoot } = require('../gsd-core/bin/lib/security.cjs');
const { loadTrustedGlobalRoots, tryWithinRoot, PathAcceptance } = require('../gsd-core/bin/lib/security.cjs');
// ─── helpers ──────────────────────────────────────────────────────────────────
@@ -915,21 +915,21 @@ describe('trusted_global_roots guard logic', () => {
test('tryWithinRoot rejects skill outside globalSkillsBase (baseline — no trusted roots)', () => {
const skillMd = path.join(externalDir, 'SKILL.md');
const result = tryWithinRoot(skillMd, tmpDir, { allowAbsolute: true });
const result = tryWithinRoot(skillMd, tmpDir, PathAcceptance.AbsoluteInsideRoot);
assert.equal(result, null, 'skill outside base must be rejected by tryWithinRoot');
});
test('with trusted root matching real target dir — tryWithinRoot accepts', () => {
// Simulate the trusted-root fallback: skill is outside base but inside trusted root
const skillMd = path.join(externalDir, 'SKILL.md');
const baseCheck = tryWithinRoot(skillMd, tmpDir, { allowAbsolute: true });
const baseCheck = tryWithinRoot(skillMd, tmpDir, PathAcceptance.AbsoluteInsideRoot);
assert.equal(baseCheck, null, 'base check must fail (prerequisite)');
// Trusted root fallback: check against externalDir
const config = { agent_skills_security: { trusted_global_roots: [externalDir] } };
const trustedRoots = loadTrustedGlobalRoots(config);
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
const rootCheck = tryWithinRoot(skillMd, root, { allowAbsolute: true });
const rootCheck = tryWithinRoot(skillMd, root, PathAcceptance.AbsoluteInsideRoot);
return rootCheck !== null;
});
assert.ok(acceptedViaTrustedRoot, 'skill must be accepted when within a trusted root');
@@ -942,7 +942,7 @@ describe('trusted_global_roots guard logic', () => {
const config = { agent_skills_security: { trusted_global_roots: [unrelatedDir] } };
const trustedRoots = loadTrustedGlobalRoots(config);
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
const rootCheck = tryWithinRoot(skillMd, root, { allowAbsolute: true });
const rootCheck = tryWithinRoot(skillMd, root, PathAcceptance.AbsoluteInsideRoot);
return rootCheck !== null;
});
assert.ok(!acceptedViaTrustedRoot, 'skill must still be rejected when trusted root is unrelated');
@@ -957,7 +957,7 @@ describe('trusted_global_roots guard logic', () => {
const trustedRoots = loadTrustedGlobalRoots(config);
assert.strictEqual(trustedRoots.length, 0, 'no roots loaded');
const acceptedViaTrustedRoot = trustedRoots.some((root) => {
const rootCheck = tryWithinRoot(skillMd, root, { allowAbsolute: true });
const rootCheck = tryWithinRoot(skillMd, root, PathAcceptance.AbsoluteInsideRoot);
return rootCheck !== null;
});
assert.ok(!acceptedViaTrustedRoot, 'skill must be rejected when trusted roots is empty');

View File

@@ -25,6 +25,7 @@ const {
validatePromptStructure,
assertWithinRoot,
tryWithinRoot,
PathAcceptance,
} = require('../gsd-core/bin/lib/security.cjs');
// ─── Path Traversal Prevention ──────────────────────────────────────────────
@@ -58,12 +59,12 @@ describe('assertWithinRoot / tryWithinRoot — engine invariance', () => {
});
test('allows absolute paths within base when opted in', () => {
const r = tryWithinRoot(path.join(base, 'src/file.js'), base, { allowAbsolute: true });
const r = tryWithinRoot(path.join(base, 'src/file.js'), base, PathAcceptance.AbsoluteInsideRoot);
assert.ok(r !== null);
});
test('rejects absolute paths outside base even when opted in', () => {
assert.equal(tryWithinRoot('/etc/passwd', base, { allowAbsolute: true }), null);
assert.equal(tryWithinRoot('/etc/passwd', base, PathAcceptance.AbsoluteInsideRoot), null);
});
test('rejects null bytes', () => {
@@ -1367,7 +1368,7 @@ describe('assertWithinRoot / tryWithinRoot — narrowed export (#4653)', () => {
});
test('returns the resolved path for an absolute input INSIDE the root when {allowAbsolute:true}', () => {
const resolved = assertWithinRoot(path.join(base, 'src/file.js'), base, null, { allowAbsolute: true });
const resolved = assertWithinRoot(path.join(base, 'src/file.js'), base, null, PathAcceptance.AbsoluteInsideRoot);
assert.equal(resolved, path.resolve(base, 'src/file.js'));
});
@@ -1376,7 +1377,7 @@ describe('assertWithinRoot / tryWithinRoot — narrowed export (#4653)', () => {
});
test('throws on an absolute path outside the root even with {allowAbsolute:true}', () => {
assert.throws(() => assertWithinRoot('/etc/passwd', base, null, { allowAbsolute: true }));
assert.throws(() => assertWithinRoot('/etc/passwd', base, null, PathAcceptance.AbsoluteInsideRoot));
});
test('throws on a null byte', () => {
@@ -1406,7 +1407,7 @@ describe('assertWithinRoot / tryWithinRoot — narrowed export (#4653)', () => {
});
test('returns the resolved path for an absolute input INSIDE the root when {allowAbsolute:true}', () => {
const resolved = tryWithinRoot(path.join(base, 'src/file.js'), base, { allowAbsolute: true });
const resolved = tryWithinRoot(path.join(base, 'src/file.js'), base, PathAcceptance.AbsoluteInsideRoot);
assert.equal(resolved, path.resolve(base, 'src/file.js'));
});
@@ -1416,7 +1417,7 @@ describe('assertWithinRoot / tryWithinRoot — narrowed export (#4653)', () => {
});
test('returns exactly null for an absolute path outside the root even with {allowAbsolute:true}', () => {
const result = tryWithinRoot('/etc/passwd', base, { allowAbsolute: true });
const result = tryWithinRoot('/etc/passwd', base, PathAcceptance.AbsoluteInsideRoot);
assert.strictEqual(result, null);
});