diff --git a/.changeset/eager-wasps-swim.md b/.changeset/eager-wasps-swim.md new file mode 100644 index 000000000..5b3750d56 --- /dev/null +++ b/.changeset/eager-wasps-swim.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2445 +--- +**`GSD_ALLOW_SYMLINKED_DEST=1` lets users with intentional symlinked configHome layouts install/update again** — 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/` or `hooks/`) was a pre-existing symlink, with no opt-out. Three legitimate user-owned layouts were blocked: multi-account configs with symlinked shared skills/hooks (POSIX symlinks), Windows Junctions to shared skills dirs, and dotfiles-managed configHome (e.g. nix-darwin symlinking `~/.claude` itself to a version-controlled dir). The new env var follows user-owned symlinks instead of refusing them, while preserving the two load-bearing refusals from the original threat model: path-traversal in the destSubpath string itself (`../../etc`-style), and a symlink resolving to the install root itself (would let the prune pass wipe it). (#2393) diff --git a/bin/install.js b/bin/install.js index 8d94508c1..0ad156757 100755 --- a/bin/install.js +++ b/bin/install.js @@ -579,6 +579,7 @@ const { _installNativePluginIfDeclared, _copyStaged, hasExistingSymlinkBetween, + isSymlinkedDestOptIn, preserveUserArtifacts, restoreUserArtifacts, migrateLegacyDevPreferencesToSkill, @@ -6923,12 +6924,14 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san // Symlink-escape guard (parity with _copyStaged / copyWithPathReplacement): the // lexical gate above does not resolve symlinks, so a pre-existing config.toml or // agents/ symlink could redirect writes outside targetDir. Reject those. + // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts. + const symlinkOptIn = isSymlinkedDestOptIn(); if ( - hasExistingSymlinkBetween(resolvedTargetRoot, configPath) || - hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir)) + hasExistingSymlinkBetween(resolvedTargetRoot, configPath, { allowOptInFollow: symlinkOptIn }) || + hasExistingSymlinkBetween(resolvedTargetRoot, path.resolve(agentsTomlDir), { allowOptInFollow: symlinkOptIn }) ) { throw new Error( - `installCodexConfig: a Codex config path under "${targetDir}" contains a symlink escaping the install root — refusing to write`, + `installCodexConfig: a Codex config path under "${targetDir}" contains a symlink the install root 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(agentsTomlDir, { recursive: true }); @@ -6979,9 +6982,9 @@ function installCodexConfig(targetDir, agentsSrc, sandboxTier = 'codex-agent-san // `name` containing path separators must not escape agents/ (which would let // it clobber config.toml or write elsewhere under the configHome). const agentTomlPath = assertDestWithinConfigHome(agentsTomlDir, `${name}.toml`); - if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath)) { + if (hasExistingSymlinkBetween(resolvedTargetRoot, agentTomlPath, { allowOptInFollow: symlinkOptIn })) { throw new Error( - `installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink escaping the install root — refusing to write`, + `installCodexConfig: agent toml path "${agentTomlPath}" contains a symlink the install root does not trust — refusing to write. If this is an intentional user-owned symlink layout, re-run with GSD_ALLOW_SYMLINKED_DEST=1.`, ); } fs.writeFileSync(agentTomlPath, tomlContent); @@ -7644,9 +7647,10 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand } const resolvedConfinementRoot = path.resolve(confinementRoot); const resolvedDestDir = assertDestWithinConfigHome(confinementRoot, destDir); - if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir)) { + // #2393: honor GSD_ALLOW_SYMLINKED_DEST for intentional user-owned symlink layouts. + if (hasExistingSymlinkBetween(resolvedConfinementRoot, resolvedDestDir, { allowOptInFollow: isSymlinkedDestOptIn() })) { throw new Error( - `copyWithPathReplacement: destDir "${destDir}" contains a symlink escaping the install root "${confinementRoot}" — refusing to write`, + `copyWithPathReplacement: destDir "${destDir}" contains a symlink the install root "${confinementRoot}" 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 all writes below so the gate validates @@ -9305,7 +9309,7 @@ function resolveInstallRelativePath(baseDir, relPath) { if (fullPath !== root && !fullPath.startsWith(root + path.sep)) { return null; } - if (hasExistingSymlinkBetween(root, fullPath)) { + if (hasExistingSymlinkBetween(root, fullPath, { allowOptInFollow: isSymlinkedDestOptIn() })) { return null; } return { relPath: normalized, fullPath }; diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 37f174b97..26acb1614 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1581,6 +1581,7 @@ Use `provider: "generic"` (or `"custom"`) for OpenRouter, LiteLLM, local gateway | `GSD_AUDIT_ARGS` | Set to `1` to include command args in audit/error events (omitted by default) | | `GSD_PROJECT` | Override project root for multi-project workspace support (v1.32) | | `GSD_SKIP_SCHEMA_CHECK` | Skip schema drift detection during execute-phase (v1.31) | +| `GSD_ALLOW_SYMLINKED_DEST` | Set to `1` (or `true`) to permit install/update when `CLAUDE_CONFIG_DIR` (or any artifact-kind child like `skills/`, `hooks/`) is an **intentional, user-owned symlink** pointing outside the install root. v1.7.x write-confinement (ADR-1239 Phase B) refuses such layouts by default to prevent untrusted `destSubpath` traversal. Opt in only if you manage configHome via symlinked external dirs, multi-account config layouts (`~/.claude-personal`, `~/.claude-team`), or dotfiles-managed configHome (nix-darwin, etc.). Two refusals remain load-bearing even with opt-in: path-traversal in `destSubpath` (`../../etc`-style), and a symlink whose resolved target equals the install root itself (would let the prune pass wipe it). | | `WSL_DISTRO_NAME` | Detected by installer for WSL path handling | --- diff --git a/src/install-engine.cts b/src/install-engine.cts index 1d7107559..ee9387325 100644 --- a/src/install-engine.cts +++ b/src/install-engine.cts @@ -182,19 +182,97 @@ function restoreUserArtifacts(destDir: string, saved: Map): 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 { }); }); } + +// ─── #2393: GSD_ALLOW_SYMLINKED_DEST opt-in for intentional symlinked-dest layouts ──── +// +// Three reporter layouts, all refused by the pre-#2393 guard with no opt-out: +// (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 D:\claude-shared-resources\skills +// (Azd325) ~/.claude itself is a symlink to a dotfiles repo (root-is-symlink) +// +// Fix: GSD_ALLOW_SYMLINKED_DEST=1 follows symlinks instead of refusing them, +// while preserving the load-bearing refusals from #1704 / ADR-1239 Phase B: +// (a) path-traversal in the destSubpath string itself ('../../etc') +// (b) a resolved symlink target equal to the install root (would let _removeGsdEntries +// wipe the root — the config-root-wipe threat) + +describe('#2393: GSD_ALLOW_SYMLINKED_DEST opt-in for intentional symlinked-dest layouts', () => { + const { hasExistingSymlinkBetween } = require('../gsd-core/bin/lib/install-engine.cjs'); + + beforeEach(() => { + delete process.env.GSD_ALLOW_SYMLINKED_DEST; + }); + + afterEach(() => { + delete process.env.GSD_ALLOW_SYMLINKED_DEST; + }); + + // Reporter case (lars-hh / Mamiki): a child component of configHome is a symlink + // to a user-owned dir outside configHome. Default refuses; opt-in follows. + test('child-symlink layout: default refuses, GSD_ALLOW_SYMLINKED_DEST=1 allows', (t) => { + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-cfg-')); + const outsideTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-out-')); + try { + const linkPath = path.join(configHome, 'skills'); + try { + fs.symlinkSync(outsideTarget, linkPath); + } catch (_e) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const destDir = path.join(linkPath, 'gsd-foo'); + + // Default: refuse (existing pre-#2393 behavior unchanged). + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir), + true, + 'default must refuse symlinked destDir (pre-#2393 behavior)', + ); + + // Opt-in: allow (user asserted they trust the target). + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), + false, + 'GSD_ALLOW_SYMLINKED_DEST=1 must allow intentional user-owned child symlink', + ); + } finally { + try { fs.unlinkSync(path.join(configHome, 'skills')); } catch { /* already gone */ } + cleanup(configHome); + cleanup(outsideTarget); + } + }); + + // Reporter case (Azd325): the install root ITSELF is a symlink. The pre-#2393 + // guard had an early-return for this before the component loop even ran. + test('root-is-symlink layout (Azd325/nix-darwin): default refuses, opt-in allows', (t) => { + const dotfilesTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-dot-')); + const rootLink = path.join(os.tmpdir(), 'gsd-2393-rootlink-' + Date.now()); + try { + try { + fs.symlinkSync(dotfilesTarget, rootLink); + } catch (_e) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + // Inside the dotfiles target, skills is a real dir (not a symlink). + fs.mkdirSync(path.join(dotfilesTarget, 'skills'), { recursive: true }); + const destDir = path.join(rootLink, 'skills', 'gsd-foo'); + + // Default: refuse (root itself is a symlink → early-return true). + assert.strictEqual( + hasExistingSymlinkBetween(rootLink, destDir), + true, + 'default must refuse when install root itself is a symlink', + ); + + // Opt-in: follow the root symlink, walk to the real skills dir — allow. + assert.strictEqual( + hasExistingSymlinkBetween(rootLink, destDir, { allowOptInFollow: true }), + false, + 'GSD_ALLOW_SYMLINKED_DEST=1 must follow a root symlink whose target has no further symlinks', + ); + } finally { + try { fs.unlinkSync(rootLink); } catch { /* already gone */ } + cleanup(dotfilesTarget); + } + }); + + // Load-bearing refusal (a): path-traversal in the destSubpath string itself. + // MUST refuse regardless of opt-in — this is the #1704 threat (a). + test('path-traversal destSubpath ("../../etc") refused EVEN WITH opt-in', () => { + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-trav-')); + try { + const escapePath = path.join(configHome, '..', '..', 'etc-passwd-' + Date.now()); + assert.strictEqual( + hasExistingSymlinkBetween(configHome, escapePath, { allowOptInFollow: true }), + true, + 'path-traversal destSubpath must ALWAYS refuse regardless of opt-in (#1704 threat a)', + ); + } finally { + cleanup(configHome); + } + }); + + // Load-bearing refusal (b): a symlink whose resolved target equals the install root + // itself would let _removeGsdEntries wipe the root. MUST refuse regardless of opt-in. + test('resolved-target-equals-install-root refused EVEN WITH opt-in (wipe protection)', (t) => { + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-wipe-')); + try { + // Symlink configHome/loop -> configHome (circular). Resolved target == install root. + const loopLink = path.join(configHome, 'loop'); + try { + fs.symlinkSync(configHome, loopLink); + } catch (_e) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const destDir = path.join(loopLink, 'gsd-foo'); + + // Default refuses. + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir), + true, + 'default must refuse symlink to install root (wipe protection)', + ); + + // Opt-in STILL refuses — this is threat (b), load-bearing even with opt-in. + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), + true, + 'opt-in must NOT allow a symlink resolving to install root itself (#1704 threat b — wipe)', + ); + } finally { + try { fs.unlinkSync(path.join(configHome, 'loop')); } catch { /* already gone */ } + cleanup(configHome); + } + }); + + // #2393 security-review finding: realpathSync fully resolves symlinks while + // path.resolve is lexical. On macOS, /var is a symlink to /private/var, so + // `resolvedRoot` carries `/var/...` while the symlink's realtarget carries + // `/private/var/...` — a naive `realtarget === resolvedRoot` check would miss + // the equality and let threat (b) through. Fix compares against BOTH the + // lexical and real forms of root. Test constructs the macOS-style divergence + // explicitly: spell configHome one way, point the symlink at its real path. + test('resolved-target-equals-install-root via /var ↔ /private/var normalization (macOS-style)', (t) => { + if (process.platform !== 'darwin') { + t.skip('test exercises the macOS /var → /private/var symlink — darwin only'); + return; + } + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-realpath-')); + try { + // `os.tmpdir()` is spelled with `/var/...` on macOS; realpathSync resolves it + // to `/private/var/...`. The lexical resolvedRoot and the real realRoot differ. + const realConfigHome = fs.realpathSync(configHome); + if (realConfigHome === configHome) { + // Defensive — if for some reason there's no /var symlink in the chain, the + // test isn't exercising what it claims. Skip rather than pass vacuously. + t.skip('os.tmpdir() path contains no symlink component — test does not exercise the /var normalization'); + return; + } + + // Symlink spelled via the REAL path — its realtarget will equal realConfigHome, + // NOT lexical configHome. The bug shape: realtarget !== resolvedRoot (lexical). + const loopLink = path.join(configHome, 'loop'); + try { + fs.symlinkSync(realConfigHome, loopLink); + } catch (_e) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const destDir = path.join(loopLink, 'gsd-foo'); + + // The fix compares against BOTH lexical and real forms — guard fires. + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), + true, + 'opt-in must refuse a symlink resolving to install root by real path even when ' + + 'lexical and real forms differ (macOS /var ↔ /private/var normalization)', + ); + } finally { + try { fs.unlinkSync(path.join(configHome, 'loop')); } catch { /* already gone */ } + cleanup(configHome); + } + }); + + // Documented edge case: a broken symlink (target missing) is silently passed by + // both the default and opt-in paths. fs.existsSync follows the link and returns + // false, so the component loop terminates before the symlink check fires. This is + // pre-existing behavior — the fix preserves it. Subsequent mkdir may then fail or + // create the path through the resolved target; that's the caller's responsibility, + // not the symlink-escape guard's. Test pins the current behavior so any future + // change (e.g. switching to lstatSync for existence) is intentional. + test('broken symlink: silently passed (current behavior, preserved by fix)', (t) => { + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-broken-')); + try { + const danglingLink = path.join(configHome, 'skills'); + const notPresentTarget = path.join(os.tmpdir(), 'gsd-2393-not-present-' + Date.now()); + try { + fs.symlinkSync(notPresentTarget, danglingLink); + } catch (_e) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const destDir = path.join(danglingLink, 'gsd-foo'); + + // existsSync(danglingLink) follows the link → false → loop returns false early. + // Same behavior with and without opt-in. Test documents this so a future + // refactor (e.g. lstatSync-based existence) is a deliberate behavior change. + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir), + false, + 'broken symlink: component loop terminates early (existsSync follows link → false)', + ); + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), + false, + 'broken symlink with opt-in: same early-termination behavior', + ); + } finally { + try { fs.unlinkSync(path.join(configHome, 'skills')); } catch { /* already gone */ } + cleanup(configHome); + } + }); + + // Reviewer-driven (Medium): transitive symlink chains. The opt-in is transitive + // and unbounded by design — once a symlink is followed, the walk continues from + // the resolved real path WITHOUT re-checking that further segments stay inside + // a confining boundary. Test pins the documented behavior so a future change is + // deliberate. (Default behavior refuses at the first symlink.) + test('transitive symlink chain: opt-in follows transitively; default refuses at first hop', (t) => { + const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-trans-')); + const outside1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-t1-')); + const outside2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2393-t2-')); + try { + // configHome/outer -> outside1, outside1/inner -> outside2 (two-hop chain). + try { + fs.symlinkSync(outside1, path.join(configHome, 'outer')); + fs.symlinkSync(outside2, path.join(outside1, 'inner')); + } catch (_e) { + t.skip('symlink creation unsupported on this platform/privilege'); + return; + } + const destDir = path.join(configHome, 'outer', 'inner', 'gsd-foo'); + + // Default: refuses at the first hop (configHome/outer is a symlink). + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir), + true, + 'default must refuse at the first symlink (configHome/outer)', + ); + + // Opt-in: follows transitively through both hops to outside2 (no threat-(b) + // match — outside2 is neither lexical nor real form of configHome). + assert.strictEqual( + hasExistingSymlinkBetween(configHome, destDir, { allowOptInFollow: true }), + false, + 'opt-in must follow transitive chain (outer → outside1 → outside2/inner) — documented transitivity', + ); + } finally { + try { fs.unlinkSync(path.join(configHome, 'outer')); } catch { /* already gone */ } + try { fs.unlinkSync(path.join(outside1, 'inner')); } catch { /* already gone */ } + cleanup(configHome); + cleanup(outside1); + cleanup(outside2); + } + }); + + // Reviewer-driven (Medium): isSymlinkedDestOptIn env-var parsing is itself + // behavioral — a typo in the env-var name or an accepted-values change would + // silently disable the opt-in. Pin the contract directly via the exported helper. + test('isSymlinkedDestOptIn: accepts only documented values (1, true)', () => { + const installEngine = require('../gsd-core/bin/lib/install-engine.cjs'); + if (typeof installEngine.isSymlinkedDestOptIn !== 'function') { + // Skipping — helper not exported in this build (assertion-only test). + return; + } + const cases = [ + { v: '1', expected: true }, + { v: 'true', expected: true }, + { v: 'TRUE', expected: false }, // only lowercase 'true' documented + { v: 'True', expected: false }, + { v: 'yes', expected: false }, + { v: 'on', expected: false }, + { v: '0', expected: false }, + { v: 'false', expected: false }, + { v: '', expected: false }, + { v: undefined, expected: false }, // unset + ]; + for (const { v, expected } of cases) { + if (v === undefined) delete process.env.GSD_ALLOW_SYMLINKED_DEST; + else process.env.GSD_ALLOW_SYMLINKED_DEST = v; + assert.strictEqual( + installEngine.isSymlinkedDestOptIn(), + expected, + `GSD_ALLOW_SYMLINKED_DEST=${JSON.stringify(v)} should yield isSymlinkedDestOptIn()=${expected}`, + ); + } + }); +});