fix(#2393): add GSD_ALLOW_SYMLINKED_DEST opt-in for intentional user-owned symlink layouts (#2445)

* fix(#2393): add GSD_ALLOW_SYMLINKED_DEST opt-in for intentional user-owned symlink layouts

Bug: v1.7.0's destSubpath write-confinement (ADR-1239 Phase B) refused
install/update whenever CLAUDE_CONFIG_DIR (or an artifact-kind child like
skills/, hooks/) was a pre-existing symlink, with no opt-out. Three
legitimate user-owned layouts were blocked:

  - (lars-hh) CLAUDE_CONFIG_DIR=~/.claude-personal with skills/hooks
              symlinked to a user-owned external dir
  - (Mamiki)  ~/.claude/skills is a Windows Junction to a shared skills dir
  - (Azd325)  ~/.claude itself is a symlink to a dotfiles repo (the early
              root-is-symlink return refused before the component loop ran)

Fix: add GSD_ALLOW_SYMLINKED_DEST env var (accepts '1' or 'true'). When
set, hasExistingSymlinkBetween follows symlinks instead of refusing them.
Cross-platform: fs.lstatSync().isSymbolicLink() returns true for both
POSIX symlinks and NTFS junctions (Node ≥ 16), so Mamiki's Junction case
is handled by the same code path.

Threat model preserved (these still refuse EVEN WITH opt-in):
  (a) path-traversal in the destSubpath string itself ('../../etc'-style)
      — ADR-1239 Phase B threat (a), untrusted destSubpath protection
  (b) a symlink whose resolved real path equals the install root itself
      — would let _removeGsdEntries wipe the root; #1704 threat (b)
  (c) broken symlinks (realpathSync throws) — fail-closed

What opt-in RELAXES specifically: the 'pre-existing symlink pointing
outside configHome' refusal — #1704 threat (c). The user has explicitly
asserted they own and trust the symlink target.

Error messages at all 4 call sites (installRuntimeArtifacts, _copyStaged,
migrateLegacyDevPreferencesToSkill, installOpencodeFamilySkills) updated
to (1) name the env var opt-in, (2) be accurate when the root itself is
a symlink (Azd325's complaint that the old message accused destDir of
'containing' a symlink when the root was the actual symlink).

Docs: docs/CONFIGURATION.md Environment Variables table updated.

Regression tests in tests/install-write-confinement.test.cjs cover:
  - child-symlink layout (lars-hh / Mamiki): default refuses, opt-in allows
  - root-is-symlink layout (Azd325): default refuses, opt-in follows
  - path-traversal '../../etc' refused EVEN WITH opt-in (threat a preserved)
  - resolved-target-equals-install-root refused EVEN WITH opt-in (threat b)
  - broken symlink refused EVEN WITH opt-in (fail-closed)

* test(#2393): import beforeEach/afterEach in install-write-confinement suite

The original file imported only { describe, test } from node:test. The new
#2393 opt-in describe block uses beforeEach/afterEach to manage the
GSD_ALLOW_SYMLINKED_DEST env var lifecycle — add them to the import.

* test(#2393): correct broken-symlink test — existsSync follows link → loop terminates early

Initial test expected broken symlinks to be refused even with opt-in. That
was wrong: fs.existsSync follows symlinks, so a broken symlink returns
false from existsSync and the component loop terminates before the symlink
check fires. Both default and opt-in paths share this behavior; the fix
preserves it.

Updates the test to pin the actual current behavior so a future refactor
(e.g. switching to lstatSync for existence) is a deliberate behavior change.

* fix(#2393): realpath the install root — guard against macOS /var ↔ /private/var

Code review (security subagent) flagged a HIGH-severity hole in the threat-(b)
preservation: realTarget (from fs.realpathSync) is fully symlink-resolved,
resolvedRoot (from path.resolve) is lexical-only. On macOS /var is a symlink
to /private/var, so resolvedRoot='/var/foo/.claude' but realConfigHome is
'/private/var/foo/.claude'. A symlink whose realtarget matches the install
root by real path would compare unequal to the lexical resolvedRoot —
defeating the wipe-protection guard exactly in the reporter's case (Azd325,
nix-darwin: ~/.claude is itself a symlink).

Fix: compute realRoot once via fs.realpathSync(resolvedRoot) at function entry
(with fail-closed fallback to lexical form on realpath failure — broken/missing
root, permission denied, exotic FS). Threat (a) path-traversal check above
still confines regardless. Compare against BOTH lexical and real forms in
both the root-symlink and component-symlink branches.

Also adds the reviewer's transitivity-trust clarification comment: once a
symlink is followed under opt-in, the walk continues from the resolved real
path WITHOUT re-checking further segments stay inside a confining boundary.
This is documented opt-in semantics — one opt-in trusts the whole reachable
tree — and the comment makes the design choice explicit so a future
maintainer doesn't add a 'follow one symlink only' expectation.

Regression test added for the macOS /var normalization case (spelled configHome
via os.tmpdir() lexically while pointing the test symlink through its realpath).
Test skips on non-darwin platforms and when os.tmpdir() has no symlink component.

* fix(#2393): root-symlink branch — do not apply threat-(b) check to root itself

Initial fix applied the wipe-threat-(b) check to the root-symlink branch
unconditionally. That was wrong: when root itself is a symlink (Azd325's
nix-darwin case), its realpath IS realRoot by construction — so the check
always fires, defeating the opt-in for exactly the case it was meant to
enable.

The wipe threat (b) does NOT apply to root being a symlink: destDir is a
CHILD of root, and resolving root just gives root's target. There is no
circular back-reference to root from a path that descends from a resolved
root. So the root-symlink branch should just follow the symlink under opt-in
and continue the walk, no threat-(b) check.

Threat (b) only fires in the COMPONENT loop, where a child symlink can
resolve back to the install root. That branch keeps the (b) check using BOTH
lexical and real forms of root (the macOS /var ↔ /private/var fix from the
prior commit).

* fix(#2393): apply opt-in at the 5 bin/install.js call sites + add env-var/transitive tests

Code review (correctness subagent) flagged a Critical coverage gap: the
initial fix updated only the 4 src/install-engine.cts call sites. Five
more call sites in bin/install.js still used the 2-arg signature, so the
opt-in env var was silently ignored on:

  - installCodexConfig (config.toml + agents/ dir + per-agent .toml paths)
    — Codex only
  - copyWithPathReplacement (the generic emit path: workflows, commands,
    staging) — ALL runtimes
  - resolveInstallRelativePath (path resolver used in various places)

Result: a user setting GSD_ALLOW_SYMLINKED_DEST=1 would see SOME refusals
disappear (engine path) and OTHERS remain (bin/install.js paths) — a
partially-applied install and a confusing UX, directly contradicting the
PR's headline claim.

Fix:
- Export isSymlinkedDestOptIn from src/install-engine.cts alongside
  hasExistingSymlinkBetween
- Import it in bin/install.js
- Update all 5 bin/install.js call sites to pass { allowOptInFollow }
- Update all 3 bin/install.js error messages to name the env var, matching
  the engine's phrasing

Also addresses reviewer's Medium test-adequacy findings:
- isSymlinkedDestOptIn env-var parsing now tested directly (accepts only
  documented '1' / 'true'; rejects 'TRUE', 'yes', 'on', '0', 'false',
  empty, unset)
- transitive symlink chain (configHome/outer → outside1 → outside2) test
  pins the documented 'transitive and unbounded' opt-in semantics so a
  future contributor can't accidentally narrow it

* chore(changeset): backfill pr:2445 in .changeset/eager-wasps-swim.md
This commit is contained in:
Tom Boucher
2026-07-20 07:56:36 -04:00
committed by GitHub
parent 517bae8d6d
commit 12e4d93b19
5 changed files with 452 additions and 22 deletions

View File

@@ -182,19 +182,97 @@ function restoreUserArtifacts(destDir: string, saved: Map<string, string>): void
// ---------------------------------------------------------------------------
/**
* Returns true if any path component between `root` and `fullPath` is a
* symbolic link (which could redirect writes outside the install root).
* Opt-in for intentional symlinked-dest layouts (#2393). When the env var is
* set to "1" or "true", `hasExistingSymlinkBetween` follows symlinks instead of
* refusing them, EXCEPT for two load-bearing cases that always refuse regardless
* of opt-in (preserving ADR-1239 Phase B's threat model):
*
* (a) The `fullPath` itself, before any symlink resolution, escapes `root`
* via `..`-traversal — protects against untrusted `destSubpath` strings
* like `../../etc`. This is the line `resolvedFullPath !== resolvedRoot
* && !resolvedFullPath.startsWith(resolvedRoot + path.sep)` below.
* (b) A symlink's resolved real path equals the install root itself — this
* would let `_removeGsdEntries` (the prune pass) wipe the install root,
* which is the config-root-wipe threat from #1704 threat model item (b).
*
* What opt-in RELAXES specifically: the "pre-existing symlink that points
* outside configHome" refusal — threat (c) in #1704. The user has asserted
* they own and trust the symlink target. The default (no env var) keeps all
* three refusals, exactly the pre-#2393 behavior.
*
* Cross-platform note: on Windows, `fs.lstatSync().isSymbolicLink()` returns
* true for both symbolic links and NTFS junctions (Node ≥ 16), so Mamiki's
* Junction case (#2393 comment) is handled by the same code path as POSIX
* symlinks.
*
* @returns true when the caller MUST refuse; false when writes may proceed.
*/
function hasExistingSymlinkBetween(root: string, fullPath: string): boolean {
function isSymlinkedDestOptIn(): boolean {
const v = process.env.GSD_ALLOW_SYMLINKED_DEST;
return v === '1' || v === 'true';
}
/**
* Returns true if any path component between `root` and `fullPath` is a
* symbolic link that would redirect writes outside the install root in a way
* the caller must refuse.
*
* When `options.allowOptInFollow` is true (caller checked `isSymlinkedDestOptIn`),
* symlinks are followed instead of refused, except for the two always-refuse
* cases documented on `isSymlinkedDestOptIn` — (a) path-traversal in `fullPath`
* itself, (b) a resolved symlink target that equals the install root (would let
* the prune pass wipe it).
*/
function hasExistingSymlinkBetween(
root: string,
fullPath: string,
options: { allowOptInFollow?: boolean } = {},
): boolean {
const resolvedRoot = path.resolve(root);
const resolvedFullPath = path.resolve(fullPath);
// (a) Path-traversal refusal — ALWAYS enforced, even with opt-in. An untrusted
// destSubpath string that escapes the install root via '..' is rejected
// regardless of user opt-in state (ADR-1239 Phase B threat (a)).
if (resolvedFullPath !== resolvedRoot && !resolvedFullPath.startsWith(resolvedRoot + path.sep)) {
return true;
}
// #2393 (security-review finding): realpathSync fully resolves all symlink
// components, path.resolve only normalizes lexically. On macOS, /var is a
// symlink to /private/var — so resolvedRoot='/var/foo/.claude' but its real
// path is '/private/var/foo/.claude'. A symlink whose real target equals the
// install root (the threat-(b) wipe case) would compare unequal without this
// normalization, defeating the guard exactly in the reporter's case (Azd325,
// nix-darwin: ~/.claude is itself a symlink). Compute realRoot once; fall
// back to the lexical form on any realpath failure (broken/missing/exotic FS)
// — threat (a) above still confines regardless.
let realRoot: string;
try {
realRoot = fs.existsSync(resolvedRoot) ? fs.realpathSync(resolvedRoot) : resolvedRoot;
} catch {
realRoot = resolvedRoot;
}
const allowFollow = options.allowOptInFollow === true;
// #2393: when root itself is a symlink (e.g. nix-darwin manages ~/.claude as a
// symlink to a dotfiles repo — Azd325's #2393 report), the pre-#2393 guard
// refused unconditionally via an early return before the component loop. The
// wipe threat (b) does NOT apply to the root itself being a symlink: destDir is
// a CHILD of root, and resolving root gives root's target — there is no
// circular back-reference to root from a path that descends from a resolved
// root. So under opt-in, just follow the root symlink and continue the walk.
// Default behavior (no opt-in) preserves the pre-#2393 refuse.
let cursor = resolvedRoot;
if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) {
return true;
if (!allowFollow) return true;
try {
cursor = fs.realpathSync(cursor);
} catch {
// realpathSync failed (broken symlink, permission denied, exotic FS) — refuse,
// matching fail-closed posture.
return true;
}
}
const relative = path.relative(resolvedRoot, resolvedFullPath);
@@ -202,7 +280,34 @@ function hasExistingSymlinkBetween(root: string, fullPath: string): boolean {
if (!segment) continue;
cursor = path.join(cursor, segment);
if (!fs.existsSync(cursor)) return false;
if (fs.lstatSync(cursor).isSymbolicLink()) return true;
if (fs.lstatSync(cursor).isSymbolicLink()) {
if (!allowFollow) return true;
// Opt-in active: follow the symlink. Refuse if the resolved target is the
// install root itself (threat (b) — would let _removeGsdEntries wipe the
// root). Other targets are acceptable per the user's explicit opt-in. A
// broken symlink (realpathSync throws) is still refused.
//
// Threat (b) check uses BOTH lexical and real forms of root to defend
// against macOS /var ↔ /private/var-style normalization gaps: realpathSync
// fully resolves, path.resolve only normalizes lexically, so a root path
// containing a symlink component would compare unequal to a realtarget
// that matches by real path. Compare both.
//
// Transitivity note: once followed, the walk continues from the resolved
// real path WITHOUT re-checking that further segments stay inside any
// confining boundary. The user's opt-in asserts trust in the target dir
// AND any further symlinks reachable through it — transitive and unbounded
// by design (one opt-in trusts the whole reachable tree). This is the
// documented opt-in semantics; do not add a "follow one symlink only"
// expectation here without revisiting the threat model.
try {
const realTarget = fs.realpathSync(cursor);
if (realTarget === realRoot || realTarget === resolvedRoot) return true; // (b)
cursor = realTarget;
} catch {
return true;
}
}
}
return false;
@@ -249,9 +354,10 @@ function migrateLegacyDevPreferencesToSkill(targetDir: string, saved: Map<string
if (fs.existsSync(skillFile)) return false;
// Symlink-escape guard: reject if any path component between targetDir and
// skillDir is a symlink that would redirect writes outside the config root.
if (hasExistingSymlinkBetween(path.resolve(targetDir), skillDir)) {
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
if (hasExistingSymlinkBetween(path.resolve(targetDir), skillDir, { allowOptInFollow: isSymlinkedDestOptIn() })) {
throw new Error(
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
`migrateLegacyDevPreferencesToSkill: skillDir "${skillDir}" contains a symlink the install root "${targetDir}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
);
}
try {
@@ -303,9 +409,10 @@ function _copyStaged(stagedDir: string, destDir: string, kind: any, configDir: s
const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
// Symlink-escape guard: reject if any path component between the install root and
// destDir is a symlink that would redirect writes outside the install root.
if (hasExistingSymlinkBetween(path.resolve(installRoot), resolvedDest)) {
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
if (hasExistingSymlinkBetween(path.resolve(installRoot), resolvedDest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
throw new Error(
`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${installRoot}" — refusing to write`,
`_copyStaged: destDir "${destDir}" contains a symlink the install root "${installRoot}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
);
}
// Use the validated absolute path for the actual writes below.
@@ -664,9 +771,12 @@ function installRuntimeArtifacts(
// resolved alternate root instead, matching assertDestWithinConfigHome's
// own root selection in createRuntimeArtifactInstallPlan.
const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest)) {
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
// Threat model from #1704 / ADR-1239 Phase B preserved: path-traversal and
// resolved-target-equals-root still refuse regardless of opt-in.
if (hasExistingSymlinkBetween(path.resolve(installRoot), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
throw new Error(
`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${installRoot}" — refusing to create`,
`installRuntimeArtifacts: destDir "${dest}" contains a symlink the install root "${installRoot}" does not trust — refusing to create. If this is an intentional user-owned symlink layout (e.g. externalized skills/hooks dir, multi-account configHome, or a dotfiles-managed configHome), re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
);
}
fs.mkdirSync(dest, { recursive: true });
@@ -805,9 +915,10 @@ function installOpencodeFamilySkills(
const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
// Symlink-escape guard: reject if any path component between targetDir and
// dest is a symlink that would redirect writes outside the config root.
if (hasExistingSymlinkBetween(path.resolve(targetDir), dest)) {
// #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts.
if (hasExistingSymlinkBetween(path.resolve(targetDir), dest, { allowOptInFollow: isSymlinkedDestOptIn() })) {
throw new Error(
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink escaping the install root "${targetDir}" — refusing to write`,
`installOpencodeFamilySkills: destDir "${dest}" contains a symlink the install root "${targetDir}" does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`,
);
}
fs.mkdirSync(dest, { recursive: true });
@@ -1209,6 +1320,7 @@ export = {
_hostBehaviors,
_copyStaged,
hasExistingSymlinkBetween,
isSymlinkedDestOptIn,
preserveUserArtifacts,
restoreUserArtifacts,
migrateLegacyDevPreferencesToSkill,