`cmdVerifyKeyLinks` resolved `from:` and `to:` with `path.join(cwd, <value>)` where the value comes verbatim from plan YAML. `path.join` normalizes `../` rather than rejecting it, so a plan travelling with a repository could name any file the process can read, and the command reports whether the link's `pattern` matched it — an arbitrary-file-read oracle reachable from `verify-phase`. Both reads now go through `validatePath` in src/security.cts, the existing realpath-based confinement seam already used at 11 call sites. Not a missing capability — a bypassed one. Two defects in that seam, found by adversarial review and fixed here because they affect all 11 callers: 1. A dangling in-project symlink escaped confinement. A link to an EXISTING outside path was refused (realpath lands outside) while a link to a MISSING outside path took the parent-resolution fallback and was accepted — an existence oracle for arbitrary absolute paths. lstat succeeds on a dangling link and throws ENOENT on a truly absent path; an unresolvable link is now refused. A symlink resolving inside the project is still accepted. 2. A canonicalized base was compared against an uncanonicalized path when a file and its parent were both missing, wrongly refusing legitimate in-project paths on any non-canonical cwd (every macOS temp dir). This was a live regression in this PR: the wave-pending classification (#1202) depends on the not-yet-created case. Resolution now walks up to the nearest existing ancestor. Two adjacent aborts fixed: the from: read sat outside the per-link try, so a non-ENOENT errno killed the whole command; and an empty from: read the cwd directory, throwing EISDIR. Both now fail per-link. The issue was filed as a fourth ADR-0174 consolidation loss. It is not one — validatePath/requireSafePath never went away, only the SDK's name for the concept did. This is an instance of epic #3473's F2 family. The ADR-0174 loss count is three. Closes #3493 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,12 +56,50 @@ export function validatePath(filePath: unknown, baseDir: unknown, opts: { allowA
|
||||
try {
|
||||
resolvedPath = fs.realpathSync(resolvedPath);
|
||||
} catch {
|
||||
const parentDir = path.dirname(resolvedPath);
|
||||
// realpathSync failed — either resolvedPath doesn't exist at all, or it's
|
||||
// a dangling symlink (the link itself exists but its target doesn't).
|
||||
// lstat (unlike stat/realpath) stats the link itself and does NOT follow
|
||||
// it, so it succeeds for a dangling symlink and throws ENOENT for a
|
||||
// genuinely absent path. That's the discriminator: without it, a dangling
|
||||
// symlink to a non-existent OUTSIDE path would fall through to the
|
||||
// parent-resolution fallback below and be re-accepted as an in-project
|
||||
// path, while a symlink to an EXISTING outside path is correctly
|
||||
// rejected via the realpathSync success branch above — a state
|
||||
// difference an attacker can use as an existence oracle for arbitrary
|
||||
// absolute paths.
|
||||
try {
|
||||
const realParent = fs.realpathSync(parentDir);
|
||||
resolvedPath = path.join(realParent, path.basename(resolvedPath));
|
||||
if (fs.lstatSync(resolvedPath).isSymbolicLink()) {
|
||||
return { safe: false, resolved: '', error: 'Path is an unresolvable symbolic link' };
|
||||
}
|
||||
} catch {
|
||||
// Parent doesn't exist either — keep the resolved path as-is
|
||||
// lstat also threw — resolvedPath (and its would-be link) genuinely
|
||||
// doesn't exist. Fall through to ancestor resolution below.
|
||||
}
|
||||
// Walk up to the nearest ancestor that exists and realpath THAT, then
|
||||
// re-append the remaining (not-yet-created) segments. This canonicalizes
|
||||
// resolvedPath the same way resolvedBase was canonicalized above,
|
||||
// regardless of how many leading directories are missing — a single
|
||||
// parent-only check would leave resolvedPath un-canonicalized whenever
|
||||
// the parent is also missing, which breaks the startsWith comparison
|
||||
// below on any non-canonical cwd (e.g. macOS /var/... vs
|
||||
// /private/var/...).
|
||||
let ancestor = path.dirname(resolvedPath);
|
||||
const remainder: string[] = [path.basename(resolvedPath)];
|
||||
for (;;) {
|
||||
try {
|
||||
const realAncestor = fs.realpathSync(ancestor);
|
||||
resolvedPath = path.join(realAncestor, ...remainder);
|
||||
break;
|
||||
} catch {
|
||||
const parent = path.dirname(ancestor);
|
||||
if (parent === ancestor) {
|
||||
// Reached filesystem root without finding an existing ancestor —
|
||||
// keep resolvedPath as-is.
|
||||
break;
|
||||
}
|
||||
remainder.unshift(path.basename(ancestor));
|
||||
ancestor = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
const normalizedBase = resolvedBase + path.sep;
|
||||
|
||||
@@ -27,6 +27,7 @@ const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod;
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
import { execGit, platformReadSync as safeReadFile } from './shell-command-projection.cjs';
|
||||
import { validatePath } from './security.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { detectSchemaFiles, checkSchemaDrift } from './schema-detect.cjs';
|
||||
import { extractTaggedBlocks } from './markdown-sectionizer.cjs';
|
||||
@@ -1222,8 +1223,39 @@ function cmdVerifyKeyLinks(cwd: string, planFilePath: string, raw: boolean): voi
|
||||
detail: '',
|
||||
};
|
||||
|
||||
const fromPath = (link['from'] as string) || '';
|
||||
const sourceContent = safeReadFile(path.join(cwd, fromPath));
|
||||
const fromRaw = link['from'];
|
||||
const fromPath = typeof fromRaw === 'string' ? fromRaw : '';
|
||||
let sourceContent: string | null = null;
|
||||
if (fromPath !== '') {
|
||||
// An empty/missing `from:` is a malformed plan, not a path-confinement
|
||||
// violation — validatePath's traversal check (and path_rejected) only
|
||||
// applies to a non-empty path that actually resolves outside the
|
||||
// project. Leave sourceContent as null so the existing not-found /
|
||||
// pending classification below runs unchanged. Note this guard is
|
||||
// narrower than it may look: `from: "."` is a non-empty string, so it
|
||||
// still reaches validatePath and safeReadFile below, and DOES read the
|
||||
// cwd directory (yielding "Source read failed: EISDIR") — this branch
|
||||
// only short-circuits the true empty-string case.
|
||||
const fromCheck = validatePath(fromPath, cwd);
|
||||
if (!fromCheck.safe) {
|
||||
// Do not echo result.error — it embeds absolute host paths.
|
||||
check['path_rejected'] = 'from';
|
||||
check['detail'] = 'Source path rejected — resolves outside the project directory';
|
||||
results.push(check);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
sourceContent = safeReadFile(fromCheck.resolved);
|
||||
} catch (err) {
|
||||
// Report the errno only — never the message or path (untrusted `from:`
|
||||
// can trigger EISDIR/EACCES, which platformReadSync re-throws for any
|
||||
// non-ENOENT errno). A single bad link must not abort the whole command.
|
||||
const code = (err as NodeJS.ErrnoException)?.code ?? 'unknown';
|
||||
check['detail'] = `Source read failed: ${code}`;
|
||||
results.push(check);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!sourceContent) {
|
||||
// Check if the missing file is promised by a plan at the same or later wave.
|
||||
const promised = getPromisedFiles();
|
||||
@@ -1269,11 +1301,28 @@ function cmdVerifyKeyLinks(cwd: string, planFilePath: string, raw: boolean): voi
|
||||
check['verified'] = true;
|
||||
check['detail'] = 'Pattern found in source';
|
||||
} else {
|
||||
const targetContent = safeReadFile(path.join(cwd, (link['to'] as string) || ''));
|
||||
const toRaw = link['to'];
|
||||
const toPath = typeof toRaw === 'string' ? toRaw : '';
|
||||
let targetContent: string | null = null;
|
||||
if (toPath !== '') {
|
||||
// An empty/missing `to:` is a malformed plan, not a
|
||||
// path-confinement violation — only a non-empty path that
|
||||
// actually resolves outside the project is path_rejected.
|
||||
const toCheck = validatePath(toPath, cwd);
|
||||
if (!toCheck.safe) {
|
||||
// Do not read a rejected `to:` — treat as no target content
|
||||
// and do not echo result.error, which embeds absolute host
|
||||
// paths.
|
||||
check['path_rejected'] = 'to';
|
||||
check['detail'] = `Pattern "${link['pattern'] as string}" not found in source; target path rejected — resolves outside the project directory`;
|
||||
} else {
|
||||
targetContent = safeReadFile(toCheck.resolved);
|
||||
}
|
||||
}
|
||||
if (targetContent && pat.test(targetContent)) {
|
||||
check['verified'] = true;
|
||||
check['detail'] = 'Pattern found in target';
|
||||
} else {
|
||||
} else if (!check['path_rejected']) {
|
||||
check['detail'] = `Pattern "${link['pattern'] as string}" not found in source or target`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user