fix(#997): ensure canonical ~/.claude/gsd-core path for plugin installs (#1207)

* fix(#997): ensure canonical ~/.claude/gsd-core path for plugin installs via SessionStart hook

Claude Code marketplace plugin installs unpack the package into the
version-pinned plugin cache and never run bin/install.js, so
~/.claude/gsd-core/ is never created. Agents, commands, and templates
markdown-@-include the canonical ~/.claude/gsd-core/... path (which
expands ~ but NOT ${CLAUDE_PLUGIN_ROOT}), so every include resolved to
nothing and agents (e.g. the executor) failed.

Add a SessionStart hook (hooks/gsd-ensure-canonical-path.js) that, on a
plugin install, symlinks the canonical path's immutable subdirs (bin,
contexts, references, templates, workflows) to the plugin's bundled
gsd-core/ tree. It changes zero @-references, is a no-op in classic
installs, preserves user-generated files (USER-PROFILE.md, STATE.md),
prunes stale links so it self-heals after `claude plugin update`, uses
Windows junctions, and rejects bundled/canonical paths that escape the
resolved plugin root (no traversal, no clobber).

Registered in HOOKS_TO_COPY (build-hooks), MANAGED_HOOKS, hooks.json
SessionStart (runs first, timeout 5), and BUNDLED_GSD_HOOK_FILES.
Behavioral regression tests folded into issue-766-plugin-manifest.test.cjs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#997): backfill changeset PR number to #1207

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-14 09:37:41 -04:00
committed by GitHub
parent cd638d5a71
commit 9e5d4b266b
11 changed files with 708 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1207
---
**Claude Code plugin installs no longer fail with empty `@~/.claude/gsd-core/...` includes** — agents, commands, and templates `@`-include the canonical `~/.claude/gsd-core/` path, but a marketplace plugin install (`claude plugin install`) never creates that directory, so every include resolved to nothing and agents (e.g. the executor) failed. A new `SessionStart` hook (`gsd-ensure-canonical-path.js`) symlinks the canonical path's immutable subdirs (`bin`, `contexts`, `references`, `templates`, `workflows`) to the plugin's bundled tree. It is a no-op in classic `bin/install.js` installs, preserves user-generated files (e.g. `USER-PROFILE.md`), prunes stale links so it self-heals after `claude plugin update`, and uses Windows junctions. (#1207)

View File

@@ -290,6 +290,7 @@ Runtime hooks that integrate with the host AI agent:
| `gsd-statusline.js` | `statusLine` | Displays model, task, directory, and context usage bar |
| `gsd-context-monitor.js` | `PostToolUse` / `AfterTool` | Injects agent-facing context warnings at 35%/25% remaining |
| `gsd-check-update.js` | `SessionStart` | Foreground trigger for the background update check |
| `gsd-ensure-canonical-path.js` | `SessionStart` | For Claude Code plugin installs, symlinks `~/.claude/gsd-core/{bin,contexts,references,templates,workflows}` to the plugin's bundled tree so `@~/.claude/gsd-core/...` includes resolve; runs first in `SessionStart`, no-op in classic installs, self-heals after `claude plugin update` (#997) |
| `gsd-check-update-worker.js` | (helper) | Background worker spawned by `gsd-check-update.js`; no direct event registration |
| `gsd-prompt-guard.js` | `PreToolUse` | Scans `.planning/` writes for prompt injection patterns (advisory) |
| `gsd-read-injection-scanner.js` | `PostToolUse` | Scans Read tool output for injected instructions in untrusted content |
@@ -735,9 +736,14 @@ Runtime Engine (Claude Code / Gemini CLI)
│ Reads: stdin (tool event JSON), /tmp/claude-ctx-{session}.json (bridge)
│ Writes: stdout (hookSpecificOutput with additionalContext warning)
│
└── SessionStart event ──► gsd-check-update.js
Reads: VERSION file
Writes: ~/.claude/cache/gsd-update-check.json (spawns background process)
└── SessionStart event
├──► gsd-ensure-canonical-path.js (runs first)
│ Reads: ${CLAUDE_PLUGIN_ROOT}/gsd-core/ (plugin installs only)
│ Writes: ~/.claude/gsd-core/{bin,contexts,references,templates,workflows} symlinks
│ (no-op in classic installs; preserves user files; self-heals)
└──► gsd-check-update.js
Reads: VERSION file
Writes: ~/.claude/cache/gsd-update-check.json (spawns background process)
```
### Context Monitor Thresholds

View File

@@ -392,6 +392,7 @@
"gsd-context-monitor.js",
"gsd-cursor-post-tool.js",
"gsd-cursor-session-start.js",
"gsd-ensure-canonical-path.js",
"gsd-graphify-update.sh",
"gsd-phase-boundary.sh",
"gsd-prompt-guard.js",

View File

@@ -520,6 +520,7 @@ Full listing: `hooks/`.
| `gsd-read-injection-scanner.js` | `PostToolUse` | Scans tool Read results for prompt-injection patterns (v1.36+, PR #2201) |
| `gsd-worktree-path-guard.js` | `PreToolUse` | Hard-blocks Edit/Write/MultiEdit with absolute paths outside the worktree root (PR #579, #260) |
| `gsd-config-reload.js` | `FileChanged` | Hot-reloads GSD config context when `.planning/config.json` changes mid-session (#770) |
| `gsd-ensure-canonical-path.js` | `SessionStart` | Symlinks `~/.claude/gsd-core/{bin,contexts,references,templates,workflows}` to the plugin's bundled tree so `@~/.claude/gsd-core/...` includes resolve in marketplace plugin installs; no-op in classic installs, self-heals after `claude plugin update` (#997) |
| `gsd-session-state.sh` | `PostToolUse` | Session-state tracking for shell-based runtimes |
| `gsd-validate-commit.sh` | `PostToolUse` | Commit validation for conventional-commit enforcement |
| `gsd-phase-boundary.sh` | `PostToolUse` | Phase-boundary detection for workflow transitions |

View File

@@ -0,0 +1,305 @@
#!/usr/bin/env node
// gsd-hook-version: {{GSD_VERSION}}
//
// gsd-ensure-canonical-path — SessionStart hook (#997)
//
// PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that
// hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows,
// templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand
// environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them.
// In a classic `bin/install.js` install the canonical path is a real directory
// holding the bundled tree, so the includes resolve. In a Claude Code
// *marketplace plugin* install the plugin manager only unpacks the package
// into the version-pinned plugin cache and never runs `bin/install.js`, so
// `~/.claude/gsd-core/` is never created and every @-include resolves to
// nothing — every agent that depends on one fails (e.g. the executor).
//
// FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT
// set and a bundled `gsd-core/` tree found beneath it), ensure
// `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts,
// references, templates, workflows) are symlinked to the plugin's bundled tree.
// This changes ZERO @-references, is a no-op in classic installs (where each
// subdir is already a real directory), preserves user-generated files
// (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals
// after `claude plugin update` rotates the version dir, and uses Windows
// junctions for symlinks on win32.
//
// SECURITY: the resolved bundled-tree path and every per-subdir link target are
// kept strictly inside the resolved plugin root (realpath-normalised, prefix-
// checked). A real (non-symlink) file or directory already sitting at a managed
// link target is NEVER clobbered.
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
// Immutable, bundled subdirectories that the canonical path must expose. These
// are the directories `@~/.claude/gsd-core/<subdir>/...` includes point into.
// User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are
// NOT in this list and are never created, moved, or deleted by this hook.
const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows'];
/**
* Resolve the canonical runtime config dir for the active runtime.
*
* Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors
* gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The
* canonical GSD tree always lives at `<configDir>/gsd-core`.
*/
function resolveConfigDir(homeDir, env) {
const envDir = env.CLAUDE_CONFIG_DIR;
if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) {
return envDir;
}
return path.join(homeDir, '.claude');
}
/**
* Locate the bundled `gsd-core/` tree beneath a plugin root.
*
* Claude Code unpacks the package so the bundled tree sits at
* `<pluginRoot>/gsd-core/`. Returns the absolute, realpath-normalised path to
* that directory, or null if it is absent / not a directory. Resolving with
* realpath collapses symlinks/.. so the subsequent containment check is sound.
*/
function resolveBundledTree(pluginRoot) {
if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) {
return null;
}
let root;
try {
root = fs.realpathSync(pluginRoot);
} catch (_) {
return null; // plugin root does not exist
}
const bundled = path.join(root, 'gsd-core');
let bundledReal;
try {
// The bundled tree must be a real directory (or a symlink to one) that
// resolves to a path inside the plugin root. realpathSync throws ENOENT/
// ENOTDIR if <pluginRoot>/gsd-core is absent, so no separate existence
// check is needed. Reject anything that does not resolve to a directory.
bundledReal = fs.realpathSync(bundled);
if (!fs.statSync(bundledReal).isDirectory()) return null;
} catch (_) {
return null;
}
// SECURITY: the resolved bundled tree must stay inside the resolved plugin
// root. A crafted symlink at <pluginRoot>/gsd-core pointing outside the root
// is rejected — we never link the canonical path at content we do not own.
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) {
return null;
}
return bundledReal;
}
/**
* The fs.symlinkSync `type` to use for a directory link on a given platform.
*
* On Windows, unprivileged users cannot create symlinks but CAN create
* junctions; 'junction' requires an absolute target (we always pass one). On
* POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable
* without a Windows host.
*/
function dirLinkType(platform) {
return platform === 'win32' ? 'junction' : 'dir';
}
/**
* Create a directory symlink (junction on win32) from linkPath -> target.
* Throws on real failure so the caller records it.
*/
function createDirLink(target, linkPath, platform) {
fs.symlinkSync(target, linkPath, dirLinkType(platform));
}
/**
* Does `linkPath` already correctly point at `expectedTarget`?
* Used to make the hook idempotent — a correct link is left untouched.
*/
function linkPointsAt(linkPath, expectedTarget) {
try {
if (!fs.lstatSync(linkPath).isSymbolicLink()) return false;
const resolved = fs.realpathSync(linkPath);
return resolved === fs.realpathSync(expectedTarget);
} catch (_) {
return false;
}
}
/**
* Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs.
*
* Pure, dependency-injected core so tests drive it with a fake home, fake
* plugin root, and explicit platform. Returns a structured result describing
* exactly what happened (never throws for ordinary conditions — only truly
* unexpected I/O errors propagate, and the thin CLI wrapper swallows those so
* a hook failure never blocks a session).
*
* @param {object} opts
* @param {string} [opts.homeDir] home directory (default os.homedir())
* @param {string} [opts.pluginRoot] CLAUDE_PLUGIN_ROOT (default from env)
* @param {string} [opts.platform] process.platform override (tests)
* @param {object} [opts.env] environment (default process.env)
* @returns {{status:string, canonicalDir?:string, bundledTree?:string,
* linked?:string[], prunedStale?:string[], preserved?:string[],
* skipped?:string[], reason?:string}}
*/
function ensureCanonicalPath(opts = {}) {
const env = opts.env || process.env;
const homeDir = opts.homeDir || os.homedir();
const platform = opts.platform || process.platform;
const pluginRoot = opts.pluginRoot !== undefined ? opts.pluginRoot : env.CLAUDE_PLUGIN_ROOT;
// Uniform result contract: every return carries the four action arrays so
// callers can read result.linked/etc without first switching on status.
const empty = { linked: [], prunedStale: [], preserved: [], skipped: [] };
// No plugin context → classic/npm install or non-plugin runtime. No-op.
const bundledTree = resolveBundledTree(pluginRoot);
if (!bundledTree) {
return { status: 'noop', reason: 'no-plugin-bundle', ...empty };
}
const configDir = resolveConfigDir(homeDir, env);
const canonicalDir = path.join(configDir, 'gsd-core');
// Inspect the canonical path itself exactly once.
// - If it is a SYMLINK, the user (or another tool) deliberately pointed the
// canonical path elsewhere. We must NOT write managed links *through* that
// symlink into a directory we do not own — bail as a no-op.
// - If it is a REAL directory with at least one REAL (non-link) managed
// subdir, this is a classic `bin/install.js` install — leave it alone.
let canonicalStat = null;
try { canonicalStat = fs.lstatSync(canonicalDir); } catch (_) { canonicalStat = null; }
if (canonicalStat && canonicalStat.isSymbolicLink()) {
return { status: 'noop', reason: 'canonical-is-symlink', canonicalDir, bundledTree, ...empty };
}
if (canonicalStat && canonicalStat.isDirectory()) {
for (const sub of MANAGED_SUBDIRS) {
try {
const subSt = fs.lstatSync(path.join(canonicalDir, sub));
if (subSt.isDirectory() && !subSt.isSymbolicLink()) {
return { status: 'noop', reason: 'classic-install', canonicalDir, bundledTree, ...empty };
}
} catch (_) { /* subdir absent — keep checking */ }
}
}
// Ensure the canonical directory exists (as a real directory). We never
// replace an existing real directory; recursive mkdir is a no-op if present.
try {
fs.mkdirSync(canonicalDir, { recursive: true });
} catch (e) {
return { status: 'error', reason: `mkdir-canonical: ${e.code || e.message}`, canonicalDir, bundledTree, ...empty };
}
const linked = [];
const prunedStale = [];
const preserved = [];
const skipped = [];
// SECURITY: prefix used to confirm every per-subdir link target resolves
// strictly inside the bundled tree. Defence-in-depth against a tampered
// bundle that ships an internally-escaping symlink at <bundledTree>/<sub>.
const bundledWithSep = bundledTree.endsWith(path.sep) ? bundledTree : bundledTree + path.sep;
for (const sub of MANAGED_SUBDIRS) {
const target = path.join(bundledTree, sub);
// Only expose subdirs the bundle actually ships, AND only when the target
// resolves to a real directory that stays inside the bundled tree. A
// subdir whose realpath escapes the bundle (e.g. a planted symlink) is
// skipped — we never point the canonical path at content outside the
// validated plugin bundle.
let targetIsDir = false;
try {
const targetReal = fs.realpathSync(target);
// A NAMED subdir must resolve strictly BELOW the bundled tree root. We do
// NOT accept targetReal === bundledTree here: a subdir that self-links to
// the tree root would otherwise be exposed at the wrong level (e.g.
// `workflows` -> the whole tree), making `@.../workflows/foo` resolve to
// `<tree>/foo` instead of `<tree>/workflows/foo`.
targetIsDir = fs.statSync(targetReal).isDirectory()
&& targetReal.startsWith(bundledWithSep);
} catch (_) { targetIsDir = false; }
if (!targetIsDir) {
skipped.push(sub);
continue;
}
const linkPath = path.join(canonicalDir, sub);
// Already a correct link → idempotent no-op.
if (linkPointsAt(linkPath, target)) {
linked.push(sub);
continue;
}
let existing = null;
try { existing = fs.lstatSync(linkPath); } catch (_) { existing = null; }
if (existing) {
// lstat().isSymbolicLink() is true for BOTH POSIX symlinks and Windows
// junctions, so this single predicate identifies every GSD-managed link.
if (existing.isSymbolicLink()) {
// A GSD-managed link that is stale or points elsewhere (e.g. previous
// plugin version after `claude plugin update`). Prune and recreate.
try {
fs.unlinkSync(linkPath);
prunedStale.push(sub);
} catch (e) {
skipped.push(sub);
continue;
}
} else {
// A REAL file or directory the user (or a classic install) owns. NEVER
// clobber it — preserve it untouched. This is the USER-PROFILE.md /
// partially-real-canonical-dir safety case.
preserved.push(sub);
continue;
}
}
try {
createDirLink(target, linkPath, platform);
linked.push(sub);
} catch (e) {
skipped.push(sub);
}
}
return {
status: 'ensured',
canonicalDir,
bundledTree,
linked,
prunedStale,
preserved,
skipped,
};
}
module.exports = {
ensureCanonicalPath,
resolveBundledTree,
resolveConfigDir,
dirLinkType,
MANAGED_SUBDIRS,
};
// CLI entry: run on SessionStart. Never block the session — any unexpected
// failure is swallowed (best-effort self-heal). Emit nothing on stdout to keep
// the hook silent in normal operation.
if (require.main === module) {
try {
ensureCanonicalPath();
} catch (_) {
// Best-effort: a canonical-path failure must never abort a session.
}
process.exit(0);
}

View File

@@ -3,6 +3,7 @@
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-ensure-canonical-path.js\"", "timeout": 5 },
{ "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js\"" }
]
}

View File

@@ -22,6 +22,7 @@ const MANAGED_HOOKS = [
'gsd-context-monitor.js',
'gsd-cursor-post-tool.js',
'gsd-cursor-session-start.js',
'gsd-ensure-canonical-path.js',
'gsd-graphify-update.sh',
'gsd-phase-boundary.sh',
'gsd-prompt-guard.js',

View File

@@ -26,6 +26,13 @@ const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`);
const HOOKS_TO_COPY = [
'gsd-check-update-worker.js',
'gsd-check-update.js',
// SessionStart canonical-path bootstrap (#997). In a Claude Code marketplace
// plugin install, ~/.claude/gsd-core is never created, so every
// `@~/.claude/gsd-core/...` include in agents/commands/templates resolves to
// nothing. This hook symlinks the canonical path's immutable subdirs to the
// plugin's bundled gsd-core/ tree; no-op in classic installs. Must ship to
// dist so the installer copies it into the target hooks/ dir.
'gsd-ensure-canonical-path.js',
// Required by gsd-check-update-worker.js at runtime — must ship alongside it
// so require('./managed-hooks-registry.cjs') resolves in the installed hooks/ dir.
'managed-hooks-registry.cjs',

View File

@@ -33,6 +33,7 @@ export const BUNDLED_GSD_HOOK_FILES: ReadonlySet<string> = Object.freeze(new Set
'hooks/gsd-context-monitor.js',
'hooks/gsd-cursor-post-tool.js',
'hooks/gsd-cursor-session-start.js',
'hooks/gsd-ensure-canonical-path.js',
'hooks/gsd-graphify-update.sh',
'hooks/gsd-phase-boundary.sh',
'hooks/gsd-prompt-guard.js',

View File

@@ -28,6 +28,8 @@ const EXPECTED_ALL_HOOKS = [
'gsd-check-update.js',
'gsd-config-reload.js',
'gsd-context-monitor.js',
// #997: SessionStart canonical-path bootstrap for plugin installs.
'gsd-ensure-canonical-path.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',

View File

@@ -13,9 +13,10 @@
* without requiring ajv in devDependencies.
*/
const { test, describe } = require('node:test');
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
@@ -416,19 +417,33 @@ describe('D: always-on hook contract drift guard', () => {
return map;
}
test('SessionStart: exactly one no-matcher group with gsd-check-update.js and no timeout', () => {
test('SessionStart: one no-matcher group with gsd-ensure-canonical-path.js then gsd-check-update.js', () => {
// #997: gsd-ensure-canonical-path.js is wired alongside gsd-check-update.js
// in the single SessionStart no-matcher group. It must run FIRST so the
// canonical ~/.claude/gsd-core path (and its @-include targets) exist before
// any other SessionStart logic that may read the bundled tree.
const map = buildHookMap();
const groups = map['SessionStart'];
assert.ok(groups, 'SessionStart must be present in hooks.json');
// There must be exactly one entry group (key '' = no matcher)
const noMatcherHooks = groups[''];
assert.ok(
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 1,
`SessionStart no-matcher group must contain exactly one hook; got: ${JSON.stringify(noMatcherHooks)}`
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 2,
`SessionStart no-matcher group must contain exactly two hooks; got: ${JSON.stringify(noMatcherHooks)}`
);
const h = noMatcherHooks[0];
assert.equal(h.script, 'gsd-check-update.js', 'SessionStart hook must be gsd-check-update.js');
assert.equal(h.timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
assert.equal(
noMatcherHooks[0].script, 'gsd-ensure-canonical-path.js',
'gsd-ensure-canonical-path.js must be the FIRST SessionStart hook (#997)'
);
assert.equal(
noMatcherHooks[0].timeout, 5,
'gsd-ensure-canonical-path.js must have a small timeout (5s) — symlink setup is fast'
);
assert.equal(
noMatcherHooks[1].script, 'gsd-check-update.js',
'gsd-check-update.js must remain a SessionStart hook'
);
assert.equal(noMatcherHooks[1].timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
});
test('PreToolUse Write|Edit group: gsd-prompt-guard.js (timeout 5) + gsd-read-guard.js (timeout 5)', () => {
@@ -512,3 +527,356 @@ describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => {
}
});
});
// ─── Section F: #997 canonical-path hook registration ────────────────────────
//
// gsd-ensure-canonical-path.js must be shipped + wired so plugin installs get a
// real ~/.claude/gsd-core directory (with the immutable bundled subdirs
// symlinked) — otherwise every `@~/.claude/gsd-core/...` include in agents /
// commands / templates resolves to nothing and agents fail (#997).
describe('F: #997 gsd-ensure-canonical-path.js is shipped and wired', () => {
const HOOK_BASENAME = 'gsd-ensure-canonical-path.js';
test('hook source file exists in hooks/', () => {
assert.ok(
fs.existsSync(path.join(ROOT, 'hooks', HOOK_BASENAME)),
`hooks/${HOOK_BASENAME} must exist on disk`
);
});
test('hook is listed in HOOKS_TO_COPY (build-hooks.js) so it ships to dist', () => {
const { HOOKS_TO_COPY } = require(path.join(ROOT, 'scripts', 'build-hooks.js'));
assert.ok(
HOOKS_TO_COPY.includes(HOOK_BASENAME),
`${HOOK_BASENAME} must be in HOOKS_TO_COPY or it never ships to hooks/dist`
);
});
test('hook is listed in MANAGED_HOOKS (staleness detection)', () => {
assert.ok(
MANAGED_HOOKS.includes(HOOK_BASENAME),
`${HOOK_BASENAME} must be in MANAGED_HOOKS so it is checked for staleness after update`
);
});
test('hook is wired in hooks.json SessionStart with ${CLAUDE_PLUGIN_ROOT}', () => {
const hooksConfig = JSON.parse(fs.readFileSync(HOOKS_JSON_PATH, 'utf-8'));
const sessionStart = hooksConfig.hooks.SessionStart || [];
let wired = false;
for (const entry of sessionStart) {
for (const hook of entry.hooks || []) {
if (hook.command && hook.command.includes(HOOK_BASENAME)) {
wired = true;
assert.ok(
hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
'canonical-path hook command must use ${CLAUDE_PLUGIN_ROOT}'
);
}
}
}
assert.ok(wired, `${HOOK_BASENAME} must be wired under SessionStart in hooks.json`);
});
});
// ─── Section G: #997 ensureCanonicalPath() behavioral regression ─────────────
//
// Drives the hook's exported pure core with fake home / fake plugin-root layouts
// to prove the actual canonical-path bootstrap behaviour: creates symlinks for a
// plugin layout, no-ops for classic installs, preserves user files, prunes stale
// links (self-heal after `claude plugin update`), and handles boundary cases
// (missing bundled dir, pre-existing real dir, pre-existing user file at a link
// target). Behavioral — calls the exported function and asserts the resulting
// filesystem state, not source text.
describe('G: #997 ensureCanonicalPath() behavioural regression', () => {
const { ensureCanonicalPath, dirLinkType, MANAGED_SUBDIRS } =
require(path.join(ROOT, 'hooks', 'gsd-ensure-canonical-path.js'));
test('win32 uses a junction; other platforms use a dir symlink', () => {
// Junction correctness is an explicit requirement but real junctions can
// only be created on Windows. Assert the platform→fs.symlinkSync type
// mapping directly so the win32 branch is covered on any host.
assert.equal(dirLinkType('win32'), 'junction', 'win32 must use a junction');
assert.equal(dirLinkType('linux'), 'dir', 'POSIX must use a dir symlink');
assert.equal(dirLinkType('darwin'), 'dir', 'POSIX must use a dir symlink');
});
let tmp;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-997-'));
});
afterEach(() => {
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- per-test temp cleanup, swallows ENOENT
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
});
// Build a fake plugin layout: <tmp>/plugin/gsd-core/<subdir>/marker.md and a
// separate fake home <tmp>/home with an (initially absent) .claude dir.
function makePluginLayout(subdirs = MANAGED_SUBDIRS) {
const pluginRoot = path.join(tmp, 'plugin');
const bundled = path.join(pluginRoot, 'gsd-core');
for (const sub of subdirs) {
fs.mkdirSync(path.join(bundled, sub), { recursive: true });
fs.writeFileSync(path.join(bundled, sub, 'marker.md'), `bundled ${sub}`);
}
const homeDir = path.join(tmp, 'home');
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
return { pluginRoot, homeDir, bundled };
}
test('plugin layout: creates ~/.claude/gsd-core with all subdirs symlinked to the bundle', () => {
const { pluginRoot, homeDir, bundled } = makePluginLayout();
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured', `expected ensured; got ${JSON.stringify(result)}`);
const canonical = path.join(homeDir, '.claude', 'gsd-core');
assert.ok(fs.existsSync(canonical), 'canonical dir must exist');
for (const sub of MANAGED_SUBDIRS) {
const linkPath = path.join(canonical, sub);
const st = fs.lstatSync(linkPath);
assert.ok(st.isSymbolicLink(), `${sub} must be a symlink`);
assert.equal(
fs.realpathSync(linkPath),
fs.realpathSync(path.join(bundled, sub)),
`${sub} link must resolve to the bundled subdir`
);
// The @-include target now resolves to real bundled content.
assert.equal(
fs.readFileSync(path.join(linkPath, 'marker.md'), 'utf-8'),
`bundled ${sub}`,
`@-include into ${sub} must resolve to bundled content (this is the #997 fix)`
);
}
assert.deepEqual(result.linked.sort(), [...MANAGED_SUBDIRS].sort());
});
test('idempotent: a second run with the same layout re-affirms links and changes nothing', () => {
const { pluginRoot, homeDir } = makePluginLayout();
ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
const second = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(second.status, 'ensured');
assert.deepEqual(second.linked.sort(), [...MANAGED_SUBDIRS].sort());
assert.deepEqual(second.prunedStale, []);
assert.deepEqual(second.preserved, []);
});
test('classic install: real bundled subdirs at canonical path → no-op (never touched)', () => {
const { pluginRoot, homeDir } = makePluginLayout();
// Simulate a classic bin/install.js layout: canonical dir is a REAL dir with
// REAL subdirs (not symlinks).
const canonical = path.join(homeDir, '.claude', 'gsd-core');
for (const sub of MANAGED_SUBDIRS) {
fs.mkdirSync(path.join(canonical, sub), { recursive: true });
fs.writeFileSync(path.join(canonical, sub, 'real.md'), `classic ${sub}`);
}
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop');
assert.equal(result.reason, 'classic-install');
for (const sub of MANAGED_SUBDIRS) {
const st = fs.lstatSync(path.join(canonical, sub));
assert.ok(st.isDirectory() && !st.isSymbolicLink(), `${sub} must stay a real dir`);
}
});
test('no plugin context: CLAUDE_PLUGIN_ROOT unset → no-op (classic/npm install path)', () => {
const { homeDir } = makePluginLayout();
const result = ensureCanonicalPath({ pluginRoot: undefined, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop');
assert.equal(result.reason, 'no-plugin-bundle');
assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core')), 'must not create canonical dir');
});
test('boundary: bundled gsd-core dir missing under plugin root → no-op', () => {
const pluginRoot = path.join(tmp, 'plugin-empty');
fs.mkdirSync(pluginRoot, { recursive: true }); // no gsd-core/ inside
const homeDir = path.join(tmp, 'home');
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop');
assert.equal(result.reason, 'no-plugin-bundle');
});
test('preserve: a real user file at a managed link target is never clobbered', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.mkdirSync(canonical, { recursive: true });
// User (or partial state) put a REAL directory at 'references' with content.
fs.mkdirSync(path.join(canonical, 'references'), { recursive: true });
fs.writeFileSync(path.join(canonical, 'references', 'USER-NOTES.md'), 'precious');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
// 'references' is a real dir → classic detection kicks in and the whole op
// is a no-op, preserving everything. Either way, the user file survives.
assert.ok(
fs.existsSync(path.join(canonical, 'references', 'USER-NOTES.md')),
'user file under a managed target must survive'
);
assert.equal(
fs.readFileSync(path.join(canonical, 'references', 'USER-NOTES.md'), 'utf-8'),
'precious'
);
void result;
});
test('preserve user-generated top-level file (USER-PROFILE.md) while linking subdirs', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.mkdirSync(canonical, { recursive: true });
// A user-generated file at the TOP of the canonical dir (not a managed
// subdir) — must never be removed. No managed subdir is real yet, so the
// hook proceeds to link them.
fs.writeFileSync(path.join(canonical, 'USER-PROFILE.md'), 'my profile');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.ok(
fs.existsSync(path.join(canonical, 'USER-PROFILE.md')),
'USER-PROFILE.md must survive canonical-path setup'
);
assert.equal(fs.readFileSync(path.join(canonical, 'USER-PROFILE.md'), 'utf-8'), 'my profile');
// And subdirs are still linked.
for (const sub of MANAGED_SUBDIRS) {
assert.ok(fs.lstatSync(path.join(canonical, sub)).isSymbolicLink(), `${sub} linked`);
}
});
test('self-heal: a stale symlink (pointing at a removed prior plugin version) is pruned and recreated', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.mkdirSync(canonical, { recursive: true });
// Simulate a stale link left by a previous plugin version that has since
// been removed (claude plugin update rotated the version dir).
const stalePrior = path.join(tmp, 'plugin-OLD', 'gsd-core', 'references');
fs.mkdirSync(stalePrior, { recursive: true });
const linkPath = path.join(canonical, 'references');
fs.symlinkSync(stalePrior, linkPath, 'dir');
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- simulate removed prior version
fs.rmSync(path.join(tmp, 'plugin-OLD'), { recursive: true, force: true });
assert.ok(!fs.existsSync(linkPath), 'precondition: link now dangles (target removed)');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.ok(result.prunedStale.includes('references'), 'stale references link must be pruned');
// Now resolves to the CURRENT bundle.
assert.equal(
fs.realpathSync(linkPath),
fs.realpathSync(path.join(pluginRoot, 'gsd-core', 'references')),
'references must now point at the current bundled tree'
);
});
test('self-heal: a managed symlink pointing at the wrong (but existing) target is repointed', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.mkdirSync(canonical, { recursive: true });
// A link to some OTHER real directory (e.g. a different plugin version still
// on disk). It is a valid link but points at the wrong place.
const otherDir = path.join(tmp, 'plugin-OTHER', 'gsd-core', 'workflows');
fs.mkdirSync(otherDir, { recursive: true });
fs.symlinkSync(otherDir, path.join(canonical, 'workflows'), 'dir');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.equal(
fs.realpathSync(path.join(canonical, 'workflows')),
fs.realpathSync(path.join(pluginRoot, 'gsd-core', 'workflows')),
'workflows must be repointed to the current bundle'
);
});
test('security: bundled gsd-core that symlinks OUTSIDE the plugin root is rejected', () => {
const pluginRoot = path.join(tmp, 'plugin-evil');
fs.mkdirSync(pluginRoot, { recursive: true });
// Attacker places a symlink at <pluginRoot>/gsd-core pointing outside root.
const outside = path.join(tmp, 'OUTSIDE');
fs.mkdirSync(path.join(outside, 'references'), { recursive: true });
fs.symlinkSync(outside, path.join(pluginRoot, 'gsd-core'), 'dir');
const homeDir = path.join(tmp, 'home');
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop', 'a bundled tree resolving outside the plugin root must be rejected');
assert.equal(result.reason, 'no-plugin-bundle');
assert.ok(
!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core', 'references')),
'must NOT link the canonical path at content outside the plugin root'
);
});
test('CLAUDE_CONFIG_DIR honoured: canonical path is created under the custom config dir', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const customCfg = path.join(tmp, 'custom-cfg');
fs.mkdirSync(customCfg, { recursive: true });
const result = ensureCanonicalPath({
pluginRoot, homeDir, platform: 'linux',
env: { CLAUDE_CONFIG_DIR: customCfg },
});
assert.equal(result.status, 'ensured');
assert.equal(result.canonicalDir, path.join(customCfg, 'gsd-core'));
assert.ok(fs.lstatSync(path.join(customCfg, 'gsd-core', 'references')).isSymbolicLink());
});
test('canonical path is itself a symlink → no-op (never writes links through a user-pointed symlink)', () => {
// A user pointed ~/.claude/gsd-core at some other directory via a symlink.
// The hook must NOT create managed links through it into a dir it does not
// own — it bails as a no-op.
const { pluginRoot, homeDir } = makePluginLayout();
const userTarget = path.join(tmp, 'user-gsd');
fs.mkdirSync(userTarget, { recursive: true });
const canonical = path.join(homeDir, '.claude', 'gsd-core');
fs.symlinkSync(userTarget, canonical, 'dir');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'noop');
assert.equal(result.reason, 'canonical-is-symlink');
// No managed links were written into the user's target directory.
for (const sub of MANAGED_SUBDIRS) {
assert.ok(
!fs.existsSync(path.join(userTarget, sub)),
`must not write ${sub} link through the user symlink`
);
}
});
test('uniform result contract: every status carries the four action arrays', () => {
const { pluginRoot, homeDir } = makePluginLayout();
const noop = ensureCanonicalPath({ pluginRoot: undefined, homeDir, platform: 'linux', env: {} });
for (const k of ['linked', 'prunedStale', 'preserved', 'skipped']) {
assert.ok(Array.isArray(noop[k]), `noop result.${k} must be an array, not undefined`);
}
const ensured = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
for (const k of ['linked', 'prunedStale', 'preserved', 'skipped']) {
assert.ok(Array.isArray(ensured[k]), `ensured result.${k} must be an array`);
}
});
test('security: a bundled subdir that symlinks OUTSIDE the bundle is skipped, not linked', () => {
// Defence-in-depth: even within a (validated) plugin root, a tampered
// bundle that ships <bundle>/references as a symlink escaping the bundle
// must NOT be exposed at the canonical path.
const { pluginRoot, homeDir, bundled } = makePluginLayout(['workflows']);
// Plant an escaping symlink at <bundle>/references → outside the bundle.
const outside = path.join(tmp, 'OUTSIDE-references');
fs.mkdirSync(outside, { recursive: true });
fs.writeFileSync(path.join(outside, 'evil.md'), 'evil');
fs.symlinkSync(outside, path.join(bundled, 'references'), 'dir');
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.ok(result.linked.includes('workflows'), 'legit subdir still linked');
assert.ok(result.skipped.includes('references'), 'escaping subdir must be skipped');
assert.ok(
!fs.existsSync(path.join(homeDir, '.claude', 'gsd-core', 'references')),
'canonical path must NOT expose the escaping subdir'
);
});
test('partial bundle: only ships some subdirs → links those, skips absent ones', () => {
const { pluginRoot, homeDir } = makePluginLayout(['references', 'workflows']);
const result = ensureCanonicalPath({ pluginRoot, homeDir, platform: 'linux', env: {} });
assert.equal(result.status, 'ensured');
assert.deepEqual(result.linked.sort(), ['references', 'workflows']);
assert.deepEqual(
result.skipped.sort(),
['bin', 'contexts', 'templates'].sort(),
'subdirs not present in the bundle must be skipped, not errored'
);
});
});