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:
@@ -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)
|
||||
|
||||
5
.changeset/gallant-hawks-tumble.md
Normal file
5
.changeset/gallant-hawks-tumble.md
Normal 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
@@ -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
|
||||
|
||||
@@ -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)`. */
|
||||
|
||||
18
src/init.cts
18
src/init.cts
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 | ||||