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:
@@ -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));
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'}`);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user