fix(#4636,#4653): close the symlink hole, revert a wrong collapse, fix six review findings

The RED checkpoint and two orthogonal reviews found eight defects. All fixed here.

THE COLLAPSE THAT WAS WRONG — installer-migrations. Routing ensureInsideConfig's
containment decision through the realpath-based canonical predicate broke four
tests, and the failure message says it plainly: "migration path escapes
configDir: extensions/gsd.cjs". That module's entire contract is that a
symlinked managed path is snapshotted, restored and backed up AS A LINK and
never dereferenced. The canonical predicate dereferences, then rejects the
result for escaping configDir — so it destroys exactly the thing the module
exists to preserve. Reverted to lexical, with the ruling recorded above the
function so it is not collapsed a third time. normalizeRelPath is the real
pre-gate there; it throws on absolute paths and '..' before this check runs.

That makes THREE deliberately-retained implementations, not two, and they share
one shape worth naming: a realpath-based predicate is the wrong tool wherever a
symlink must be PRESERVED rather than resolved. CONTEXT.md and
docs/explanation/security-model.md are corrected — both previously described
ensureInsideConfig as collapsed.

THE MISSED CONSUMER. tests/security-prompt-injection.security.test.cjs
destructures validatePath from the compiled lib; un-exporting it turned five
tests into TypeError. It appeared in my own earlier search output and I did not
follow it up. Translated under the same rule as the rest: assertions on the
rejection REASON go through assertWithinRoot, boolean-only through
tryWithinRoot.

VALIDATE-ONE-PATH-USE-ANOTHER, FOUND TWICE MORE. This is the fourth and fifth
occurrence in this epic of the exact defect it exists to prevent.
  - scripts/check-glossary-refs.cjs decided containment on `token` and then
    stat'd a separately re-joined path.join(ROOT, token). The ContainedPath is
    now carried through to the probe, so the validated value is the probed one.
  - src/init.cts computed skillPathContained and DISCARDED it, re-joining from
    the raw input for the existsSync and read. The branded type exists to make
    that a type error and here it was inert.

AND THE OVER-CORRECTION OF THAT FIX, caught before it shipped. The first attempt
also substituted the validated value into the EMITTED `ref` for a global skill.
That value is a display token, not a path anything reads through — the only fs
access in that branch runs on the lexical path beforehand — so substituting it
changed emitted output two ways: it is realpath-resolved, so a symlinked global
skills directory would have emitted its resolved target instead of the user's
own path, and it came from path.join, so Windows would have emitted a backslash
where the template has a literal '/'. Restored, with the distinction recorded:
the containment check there is a GATE, not a path producer.

A TEST THAT COULD NOT FAIL. The first symlink regression planted its symlink
from inside a hooked fs.readdirSync and never asserted the planting happened —
if the hook did not fire, the "nothing was written outside" assertion passed
trivially, green against vulnerable code. It now asserts the plant, matching its
sibling. The other two were re-checked: one already asserted its equivalent, the
other plants synchronously and cannot silently no-op.

THE SYMLINK FIX ITSELF, now that the tests are proven red on the matrix.
isPathConfined is lexical by design and structurally cannot see a symlink; three
callers relied on it with no defense of their own. install-engine.cts:1608 and
install-profiles.cts:880 refuse to mkdir/write through a link — mkdirSync with
recursive:true does NOT throw on an existing symlink-to-directory, so a planted
link redirected the SKILL.md write outside the install root.
install-profiles.cts:755 refuses to read through one — statSync FOLLOWS links,
so an outside file's contents were returned and installed as a skill body. Each
mirrors the guard retired-artifact-cleanup.cts:77 already uses.

Severity stated accurately rather than dramatically: only the read at :755 needs
no race. _removeGsdEntries sweeps a pre-planted link at :1608 before the write
loop, and :880's stageDir is a fresh mkdtemp, so both of those require winning a
window. They are fixed as defense-in-depth, not as live exploits.

ALSO: the Changed changeset claimed "every command's observable behavior [is]
unchanged". Three rejection messages are reworded. It now says so, and says that
none of them reveals a host path it previously hid. A stale comment in
verify.cts still named validatePath; an init.cts warning hardcoded "resolves
outside the project directory" for a check that also rejects absolute paths, NUL
bytes and empty strings; and the rationale deleted with check-glossary-refs'
retired helper is restored, noting honestly that a rejected token is now
realpath-resolved before rejection rather than rejected by string comparison.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
sim
2026-09-12 13:59:52 -04:00
parent 9953d02184
commit 6f0e5ccf85
12 changed files with 116 additions and 42 deletions

View File

@@ -2,4 +2,4 @@
type: Changed
pr: 0
---
**The path-containment predicate is now a single exported seam** — `security.cjs` no longer exports `validatePath`; `assertWithinRoot` (throws), `tryWithinRoot` (returns null) and `requireSafePath` are the only containment exports, and all three return a branded `ContainedPath` so a validated path cannot be silently swapped for an unvalidated one. The per-call-site `{ allowAbsolute: true }` flag is replaced by the named `PathAcceptance` policy, which states what it actually permits: an absolute path outside the root was always rejected and still is. Rejection message text and every command's observable behavior are unchanged. (#4653)
**The path-containment predicate is now a single exported seam** — `security.cjs` no longer exports `validatePath`; `assertWithinRoot` (throws), `tryWithinRoot` (returns null) and `requireSafePath` are the only containment exports, and all three return a branded `ContainedPath` so a validated path cannot be silently swapped for an unvalidated one. The per-call-site `{ allowAbsolute: true }` flag is replaced by the named `PathAcceptance` policy, which states what it actually permits: an absolute path outside the root was always rejected and still is. The traversal rejection text `Path escapes allowed directory: <resolved> is outside <base>` is preserved verbatim, and no command changes what it accepts or rejects. Three rejection MESSAGES are reworded, none of which now reveals a host path it previously hid: `state.cts`'s `<label> path rejected: …` becomes `<label> path validation failed: …`, and the sub-repo and agent-skills warnings name the condition instead of echoing the predicate's error string. (#4653)

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 0
---
**Installed capability skills can no longer be redirected or leaked through a symlink** — the three install paths that confine a capability skill name relied on a lexical check, which cannot see a symlink. A link planted at the destination let `mkdirSync` succeed silently and the SKILL.md write land outside the install root, and a link planted at a capability's own SKILL.md was followed by `statSync` so an outside file's contents were installed as a skill body. All three now refuse to write or read through a link. (#4636)

File diff suppressed because one or more lines are too long

View File

@@ -162,17 +162,31 @@ module is the central security utility. It provides:
- Shell argument validation: arguments passed to subshell commands are
validated before use
Two containment checks elsewhere in the tree are deliberately NOT routed through
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.
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
for a restore that is still wrong, because writing through the link overwrites
whatever it points at instead of materializing a regular file at the backed-up
path. And `isPathConfined` in `src/external-descriptor-trust.cts` is lexical by
path. `isPathConfined` in `src/external-descriptor-trust.cts` is lexical by
design, because two install callers must validate a destination *before* the
`mkdirSync` that creates it, where `realpath` cannot resolve. A lexical check
cannot see a symlink, so callers that rely on it for a write-confinement
guarantee must pair it with their own symlink refusal.
`mkdirSync` that creates it, where `realpath` cannot resolve. And
`ensureInsideConfig` in `src/installer-migrations.cts` is lexical because that
module's contract is that a symlinked managed path is snapshotted, restored and
backed up *as a link* and never dereferenced — resolving it would dereference
precisely the links the module exists to preserve, and then reject them for
escaping the config directory.
A lexical check cannot see a symlink, so callers that rely on one for a
write-confinement guarantee must pair it with their own symlink refusal. Three
install call sites did not, and now do: a link planted at a capability skill's
destination made `mkdirSync` succeed silently and redirected the write outside
the install root, and a link planted at a capability's own `SKILL.md` was
followed by `statSync`, so an outside file's contents were installed as a skill
body.
**Runtime hook: `gsd-prompt-guard.js`.** This hook fires on every Write or
Edit call that targets `.planning/` files. It scans the content being written

View File

@@ -154,7 +154,11 @@ function isTracked(token) {
* (CONTRIBUTING's "Do not compute a next number locally"), never a real path.
*/
function extractTrackedRefs(text) {
const tokens = new Set();
// Maps token -> the ContainedPath tryWithinRoot returned for it. ADR-4650:
// the value that was validated for containment must be the exact value
// that gets probed later — never a path re-derived (e.g. re-joined) from
// the token, which could diverge from what was actually checked.
const tokens = new Map();
const add = (raw) => {
if (!PATH_TOKEN_RE.test(raw)) return;
const token = raw.replace(/:\d+$/, '');
@@ -164,9 +168,18 @@ function extractTrackedRefs(text) {
if (!/[A-Za-z0-9_]$/.test(token)) return;
if (token.includes('NNNN')) return;
if (!isTracked(token)) return;
// Containment decision is the canonical predicate's, per ADR-4650.
if (tryWithinRoot(token, ROOT, PathAcceptance.AbsoluteInsideRoot) === null) return;
tokens.add(token);
// Containment decision is the canonical predicate's, per ADR-4650. Carry
// the returned ContainedPath forward so checkFileRefs stats the SAME
// value that was validated, instead of re-joining `token` onto ROOT.
//
// The containment ANSWER is unchanged from the retired lexical-only
// `isWithinRoot`, but the canonical predicate resolves symlinks, so a
// rejected token is now realpath-resolved before being rejected rather
// than rejected by string comparison alone; the result is still never
// surfaced and the token is never stat'd unless it is contained.
const contained = tryWithinRoot(token, ROOT, PathAcceptance.AbsoluteInsideRoot);
if (contained === null) return;
tokens.set(token, contained);
};
const subTokenRe = /[\w.-]+(?:\/[\w.-]+)*/g;
for (const line of text.split(/\r?\n/)) {
@@ -187,14 +200,17 @@ function extractTrackedRefs(text) {
/** Check A: every tracked reference must resolve on disk. */
function checkFileRefs(contextText) {
const tokens = [...extractTrackedRefs(contextText)].sort();
const entries = [...extractTrackedRefs(contextText)].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
const findings = [];
for (const token of tokens) {
if (!fs.existsSync(path.join(ROOT, token))) {
for (const [token, contained] of entries) {
// Stat the ContainedPath returned by tryWithinRoot — NOT a re-joined
// path.join(ROOT, token) — so the path that was validated for
// containment is the path that is probed (ADR-4650).
if (!fs.existsSync(contained)) {
findings.push(`CONTEXT.md references \`${token}\` which does not exist in the repo.`);
}
}
return { findings, checked: tokens.length };
return { findings, checked: entries.length };
}
/** The glossary's own claim: `Runtime enum: `allRuntimes` (N values: a, b, c)`. */

View File

@@ -4017,9 +4017,9 @@ function buildAgentSkillsBlock(
}
const globalSkillMdContained = tryWithinRoot(globalSkillMd, globalSkillsBase, PathAcceptance.AbsoluteInsideRoot);
if (globalSkillMdContained === null) {
const acceptedViaTrustedRoot = trustedGlobalRoots.some((root) => {
return tryWithinRoot(globalSkillMd, root, PathAcceptance.AbsoluteInsideRoot) !== null;
});
const acceptedViaTrustedRoot = trustedGlobalRoots.some(
(root) => tryWithinRoot(globalSkillMd, root, PathAcceptance.AbsoluteInsideRoot) !== null,
);
if (!acceptedViaTrustedRoot) {
warn(
`[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`,
@@ -4030,6 +4030,13 @@ function buildAgentSkillsBlock(
// trace, not a skip, so it must not land in the diagnostics warnings[].
process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`);
}
// `ref` is an emitted display token, not a path anything reads or writes
// through — the containment check above is a gate, not a path producer.
// Emitting the validated (realpath-resolved, platform-separator) value
// instead of this literal broke symlinked skill dirs and Windows output.
// The only filesystem read here (existsSync above) already ran on the
// lexical path before containment was checked, so ADR-4650's "use the
// validated value" rule doesn't apply to this emission.
validEntries.push({ kind: 'include', ref: `${globalSkillDir}/SKILL.md`, display: displayPath });
continue;
}
@@ -4037,12 +4044,13 @@ function buildAgentSkillsBlock(
const skillPathContained = tryWithinRoot(skillPath, projectRoot);
if (skillPathContained === null) {
warn(
`[agent-skills] WARNING: Skipping unsafe path "${skillPath}": resolves outside the project directory\n`,
`[agent-skills] WARNING: Skipping unsafe path "${skillPath}": not confined to the project directory\n`,
);
continue;
}
const skillMdPath = path.join(projectRoot, skillPath, 'SKILL.md');
// ADR-4650: the validated value is the value used — never re-derive from raw input.
const skillMdPath = path.join(skillPathContained, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
// #2941: if the bare name matches a global skill, hint at the global: prefix.
// The bare name resolves as project-relative (which doesn't exist), but the

View File

@@ -1610,6 +1610,13 @@ function installOpencodeFamilySkills(
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
content = processAttribution(content, resolveAttribution(runtime));
const skillDir = path.join(dest, skillName);
// isPathConfined is lexical and cannot see a symlink. mkdirSync({recursive:true})
// does NOT throw when skillDir already exists as a symlink to a directory, so a
// pre-planted link would redirect the SKILL.md write outside `dest`. Refuse to
// write through a link (epic #4636; mirrors retired-artifact-cleanup.cts:77).
try {
if (installFs().lstatSync(skillDir).isSymbolicLink()) continue;
} catch { /* ENOENT: not created yet — the normal case */ }
installFs().mkdirSync(skillDir, { recursive: true });
installFs().writeFileSync(path.join(skillDir, 'SKILL.md'), content);
// #2322 HIGH-3 parity: persist the capability-owned marker so a later

View File

@@ -755,6 +755,10 @@ function readInstalledCapabilitySkill(stem: string, registry: CapabilityRegistry
if (!isPathConfined(relSkillPath, capDir)) return null;
const skillPath = path.join(capDir, relSkillPath);
try {
// statSync follows symlinks; isPathConfined is lexical and cannot see one.
// Refuse to read through a link so an outside file's content cannot be
// installed as a capability skill (epic #4636).
if (fs.lstatSync(skillPath).isSymbolicLink()) return null;
if (!fs.statSync(skillPath).isFile()) return null;
return { capId, content: fs.readFileSync(skillPath, 'utf8') };
} catch {
@@ -879,6 +883,13 @@ function stageSkillsForRuntimeAsSkills(
const skillName = `${prefix}${stem}`;
if (!isPathConfined(skillName, stageDir)) continue; // defense-in-depth
const destDir = path.join(stageDir, skillName);
// isPathConfined is lexical and cannot see a symlink. mkdirSync({recursive:true})
// does NOT throw when destDir already exists as a symlink to a directory, so a
// pre-planted link would redirect the SKILL.md write outside `stageDir`. Refuse
// to write through a link (epic #4636; mirrors retired-artifact-cleanup.cts:77).
try {
if (installFs().lstatSync(destDir).isSymbolicLink()) continue;
} catch { /* ENOENT: not created yet — the normal case */ }
installFs().mkdirSync(destDir, { recursive: true });
installFs().writeFileSync(path.join(destDir, 'SKILL.md'), found.content);
// #2322 HIGH-3: persist the capability-owned marker so a later prune

View File

@@ -19,7 +19,6 @@ 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 { tryWithinRoot, PathAcceptance } 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
@@ -665,18 +664,26 @@ interface EnsureInsideConfigResult {
fullPath: string;
}
// DELIBERATELY LEXICAL — do not route this through the canonical containment
// predicate (`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
// ("migration path escapes configDir: extensions/gsd.cjs"). Four tests in
// tests/installer-migrations.test.cjs pin that behavior.
//
// `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);
// fullPath stays the LEXICAL path.resolve result (not the canonical
// predicate's realpath-resolved value): both callers (readJson's
// ensureInsideConfig call and the migration-apply loop) use fullPath for
// fs.existsSync checks and journal entries, and those must not shift if
// configDir happens to be a symlink. Per ADR-4650 decision 6, the
// containment DECISION (whether fullPath is inside configDir) is owned by
// the canonical predicate — this wrapper only decides how to degrade
// (throw with this file's existing message), never whether contained.
const fullPath = path.resolve(configDir, normalized);
if (tryWithinRoot(fullPath, configDir, PathAcceptance.AbsoluteInsideRoot) === null) {
const root = path.resolve(configDir);
if (fullPath !== root && !fullPath.startsWith(root + path.sep)) {
throw new Error(`migration path escapes configDir: ${relPath}`);
}
return { normalized, fullPath };

View File

@@ -1549,7 +1549,7 @@ function cmdVerifyKeyLinks(cwd: string, planFilePath: string, raw: boolean): voi
// 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
// still reaches tryWithinRoot 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 fromContained = tryWithinRoot(fromPath, cwd);

View File

@@ -4470,6 +4470,7 @@ describe('#4636 RED: capability-skill symlink escape (isPathConfined has no real
null,
`installOpencodeFamilySkills threw unexpectedly: ${installOpencodeFamilySkillsErr && installOpencodeFamilySkillsErr.message}`,
);
assert.ok(planted, 'the symlink was never planted — this test would pass vacuously');
assert.strictEqual(
fs.existsSync(path.join(outsideDir, 'SKILL.md')),
false,

View File

@@ -95,7 +95,9 @@ const FIXTURE_DIR = path.join(__dirname, 'fixtures', 'adversarial', 'security');
const {
scanForInjection,
sanitizeForPrompt,
validatePath,
assertWithinRoot,
tryWithinRoot,
PathAcceptance,
validateShellArg,
validatePhaseNumber,
validateFieldName,
@@ -618,20 +620,24 @@ describe('validatePath: hostile path values are rejected before write', () => {
afterEach(() => { cleanup(tmpDir); });
test('parent-directory traversal is rejected', () => {
const r = validatePath('../../etc/passwd', path.join(tmpDir, '.planning'));
assert.strictEqual(r.safe, false);
assert.ok(typeof r.error === 'string' && r.error.length > 0);
assert.throws(
() => assertWithinRoot('../../etc/passwd', path.join(tmpDir, '.planning'), 'test'),
/.+/,
);
});
test('absolute path outside base is rejected', () => {
const r = validatePath('/etc/passwd', path.join(tmpDir, '.planning'), { allowAbsolute: true });
assert.strictEqual(r.safe, false);
assert.equal(
tryWithinRoot('/etc/passwd', path.join(tmpDir, '.planning'), PathAcceptance.AbsoluteInsideRoot),
null,
);
});
test('null byte in path is rejected', () => {
const r = validatePath('plan.md', path.join(tmpDir, '.planning'));
assert.strictEqual(r.safe, false);
assert.match(r.error, /null byte/i);
assert.throws(
() => assertWithinRoot('plan.md', path.join(tmpDir, '.planning'), 'test'),
/null byte/i,
);
});
test('symlink escaping the base is rejected', () => {
@@ -640,8 +646,7 @@ describe('validatePath: hostile path values are rejected before write', () => {
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3596-escape-'));
const linkInside = path.join(base, 'escape-link');
fs.symlinkSync(outside, linkInside);
const r = validatePath('escape-link/anything', base);
assert.strictEqual(r.safe, false,
assert.equal(tryWithinRoot('escape-link/anything', base), null,
'a symlink whose target is outside the base must fail containment');
// Cleanup the outside dir; the link itself is cleaned by cleanup(tmpDir).
cleanup(outside);