* fix(#1577): isolate WebFetch/WebSearch ingress + opt-in injection blocking Split A of #1573 (security-critical). Scans WebFetch/WebSearch output (the largest untrusted channel) in gsd-read-injection-scanner; shared untrusted-input-boundary reference @-included by the 8 ingest agents (randomized per-wrap delimiters, in-prompt self-scan guard, task-anchoring); opt-in security.injection_blocking (default advisory — non-breaking). arXiv: 2506.05739 (PPA), 2507.15219 (PromptArmor), 2504.20472 (Referencing), 2503.00061 (defense-in-depth). * fix(#1577): address review — honest blocking docs, config key, ADR, property test, revert localized - A1: rewrote the opt-in-blocking doc + Security changeset honestly — the PostToolUse hook is a circuit-breaker (halts the agent's next step), NOT a redactor; it does not scrub content already in the transcript. The prompt-level data/instruction boundary is the primary control. - A2: registered security.injection_blocking in the config schema + defaults manifests (default false) + an e2e config-roundtrip test; the dotted setter writes the nested shape the hook reads. - A3: reverted the 4 hand-edited localized security-model.md (canonical EN only, per convention). - A5: ADR-1577 (untrusted-input boundary + opt-in blocking; redaction-vs-circuit-breaker rationale). - A6: property test — scanner never crashes / only emits valid JSON on unicode/large/malformed input. - Also: inventory (untrusted-input-boundary.md) + agent-size baseline (8 ingest agents) + drift-guard matcher update (Read -> Read|WebFetch|WebSearch). A7 (content<20 early-exit) left as the noted pre-existing follow-up. * fix(#1577): allowlist untrusted-input-boundary.md in injection-scan CI gate The new reference quotes injection phrases ('ignore previous instructions', 'you are now…') as examples agents must NOT comply with, tripping the repo's own prompt-injection-scan.sh diff gate (the standalone 'security' CI job, red on HEAD). Allowlist it alongside the other security docs (security-model.md, TEST-EXAMPLES.md) that legitimately demonstrate injection patterns. The JS scanner test doesn't scan references/, so only the shell gate needed it. Verified: scan --diff origin/next -> 0 findings; scanner JS test 15/15. * fix(#1577): cover AC #2's gsd-ui-researcher + gsd-assumptions-analyzer trek-e Major 1: the @-included set dropped two AC #2 agents. Restore them so no named web-ingress agent is uncovered, keeping the two justified additions (gsd-ai-researcher, gsd-domain-researcher). Final set = AC's 8 + 2 = 10. - gsd-ui-researcher carries the full WebSearch/WebFetch + MCP-fetch toolset. - gsd-assumptions-analyzer reads 5-15 codebase source files (external/source- document ingress per the boundary), though it has no web tools. INGEST_AGENTS in the isolation test now asserts all 10; size baselines regenerated (+60 bytes each, both well under the DEFAULT cap); changeset reworded 8 -> 10. Verified: untrusted-input-isolation 14/14; agent-size-budget 39/39. * docs(#1577): document security.injection_blocking + boundary seam trek-e Major 2 + Minor: - docs/CONFIGURATION.md: add the top-level security.injection_blocking key to the Full Schema and a Security Settings subsection, distinguishing it from the workflow.security_* namespace; honest circuit-breaker-not-redactor framing matching ADR-1577 / security-model. - CONTEXT.md: add the 'Untrusted-input boundary' seam glossary entry. Verified: lint:docs ok; config-field-docs + contributor-standards green. * test(#1577): make read-injection property test git-text, not binary trek-e nit (and more): the file embedded a raw U+FFFF AND a raw NUL byte as degenerate-edge inputs. The NUL is what actually made git classify it binary (git binary = NUL in first 8K). Replace both with text-safe escapes that keep the identical runtime values: '\\x00' and String.fromCodePoint(0xFFFF). File now diffs/blames line-by-line. Verified: property test 2/2; no NUL/raw-noncharacter bytes remain. * docs(#1577): align untrusted boundary docs Name all 10 ingress agents in INVENTORY/security-model and allowlist the intentional read-injection property corpus for the prompt-injection scanner. * docs(#1577): align ADR ingest agent count Update ADR-1577 from 8 to 10 ingest agents so it matches the actual boundary include set and the rest of the docs. --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
955 lines
44 KiB
JavaScript
955 lines
44 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression tests for issue #766: additive Claude Code plugin manifest.
|
|
*
|
|
* Asserts structural and semantic correctness of:
|
|
* .claude-plugin/plugin.json — plugin manifest
|
|
* hooks/hooks.json — plugin hook wiring
|
|
*
|
|
* Section C1 validates plugin.json against the snapshotted schema fixture
|
|
* (tests/fixtures/plugin-manifest-schema.json) using explicit structural
|
|
* assertions instead of an Ajv dependency, so this gate runs unconditionally
|
|
* without requiring ajv in devDependencies.
|
|
*/
|
|
|
|
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');
|
|
|
|
const ROOT = path.resolve(__dirname, '..');
|
|
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
|
const pkg = require(path.join(ROOT, 'package.json'));
|
|
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
|
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
|
|
|
|
// ─── Section A: plugin.json ───────────────────────────────────────────────────
|
|
describe('A: .claude-plugin/plugin.json', () => {
|
|
|
|
let manifest;
|
|
|
|
test('exists and is valid JSON', () => {
|
|
assert.ok(fs.existsSync(PLUGIN_JSON_PATH), '.claude-plugin/plugin.json must exist');
|
|
const raw = fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8');
|
|
manifest = JSON.parse(raw); // throws on invalid JSON
|
|
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
|
|
});
|
|
|
|
test('name equals identity.binName ("gsd-core")', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`);
|
|
});
|
|
|
|
test('name is kebab-case, no colons, spaces, or uppercase', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.match(
|
|
manifest.name,
|
|
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
'name must be kebab-case (no colon, space, or uppercase) to be namespace-safe'
|
|
);
|
|
});
|
|
|
|
test('version matches package.json version', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.version, pkg.version, `.claude-plugin/plugin.json version (${manifest.version}) must match package.json version (${pkg.version}). When bumping the package version, update .claude-plugin/plugin.json \`version\` to match — Claude Code plugin --strict validation requires a version field and the plugin manifest must track the package version. (#766)`);
|
|
});
|
|
|
|
test('repository equals identity.repoUrl', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.repository, identity.repoUrl, 'repository must equal identity.repoUrl');
|
|
});
|
|
|
|
test('homepage equals identity.repoUrl', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.homepage, identity.repoUrl, 'homepage must equal identity.repoUrl');
|
|
});
|
|
|
|
test('license matches package.json license', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.license, pkg.license, 'license must match package.json');
|
|
});
|
|
|
|
test('author.name is a non-empty string', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(
|
|
manifest.author && typeof manifest.author.name === 'string' && manifest.author.name.trim().length > 0,
|
|
'author.name must be a non-empty string'
|
|
);
|
|
});
|
|
|
|
test('commands field is "./commands/gsd/" and that dir exists with at least one .md file', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.commands, './commands/gsd/', 'commands must be "./commands/gsd/"');
|
|
const resolvedDir = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.commands);
|
|
assert.ok(fs.existsSync(resolvedDir), `resolved commands dir must exist: ${resolvedDir}`);
|
|
const mdFiles = fs.readdirSync(resolvedDir).filter(f => f.endsWith('.md'));
|
|
assert.ok(mdFiles.length > 0, `commands dir must contain at least one .md file`);
|
|
});
|
|
|
|
test('hooks field is "./hooks/hooks.json" and that file exists', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.equal(manifest.hooks, './hooks/hooks.json', 'hooks must be "./hooks/hooks.json"');
|
|
const resolvedHooks = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.hooks);
|
|
assert.ok(fs.existsSync(resolvedHooks), `resolved hooks file must exist: ${resolvedHooks}`);
|
|
});
|
|
|
|
test('no "$schema" key (intentionally omitted)', (t) => {
|
|
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
|
assert.ok(!Object.prototype.hasOwnProperty.call(manifest, '$schema'), 'plugin.json must NOT contain a $schema key');
|
|
});
|
|
});
|
|
|
|
// ─── Section B: hooks/hooks.json ─────────────────────────────────────────────
|
|
describe('B: hooks/hooks.json', () => {
|
|
|
|
let hooksConfig;
|
|
|
|
test('exists and is valid JSON with top-level "hooks" object', () => {
|
|
assert.ok(fs.existsSync(HOOKS_JSON_PATH), 'hooks/hooks.json must exist');
|
|
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
|
hooksConfig = JSON.parse(raw);
|
|
assert.ok(
|
|
typeof hooksConfig === 'object' && hooksConfig !== null &&
|
|
typeof hooksConfig.hooks === 'object' && hooksConfig.hooks !== null,
|
|
'hooks.json must have a top-level "hooks" object'
|
|
);
|
|
});
|
|
|
|
test('every event name is a known Claude Code lifecycle event', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
// Complete set of Claude Code hook events as of #770 (SubagentStop, Stop,
|
|
// PreCompact, FileChanged added in #770; prior set was SessionStart,
|
|
// PreToolUse, PostToolUse from #766).
|
|
const validEvents = new Set([
|
|
'SessionStart', 'PreToolUse', 'PostToolUse',
|
|
'SubagentStop', 'Stop', 'PreCompact', 'FileChanged',
|
|
]);
|
|
for (const eventName of Object.keys(hooksConfig.hooks)) {
|
|
assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`);
|
|
}
|
|
});
|
|
|
|
test('every hook entry has type "command" and command contains ${CLAUDE_PLUGIN_ROOT}', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
|
|
assert.ok(Array.isArray(eventEntries), `Event "${eventName}" must be an array`);
|
|
for (const entry of eventEntries) {
|
|
assert.ok(Array.isArray(entry.hooks), `Entry in "${eventName}" must have a hooks array`);
|
|
for (const hook of entry.hooks) {
|
|
assert.equal(hook.type, 'command', `All hook entries must have type "command" (got "${hook.type}")`);
|
|
assert.ok(
|
|
typeof hook.command === 'string' && hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
|
`Hook command must contain "\${CLAUDE_PLUGIN_ROOT}": ${hook.command}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
test('every referenced script file exists on disk and its basename is in MANAGED_HOOKS', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
// Extract script path: substring after ${CLAUDE_PLUGIN_ROOT}/ up to next "
|
|
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)/g;
|
|
const allScripts = [];
|
|
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
|
for (const entry of eventEntries) {
|
|
for (const hook of entry.hooks) {
|
|
const matches = [...hook.command.matchAll(scriptPathRe)];
|
|
for (const m of matches) {
|
|
allScripts.push(m[1]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.ok(allScripts.length > 0, 'Should have found at least one script path in hooks.json');
|
|
for (const scriptPath of allScripts) {
|
|
const fullPath = path.join(ROOT, scriptPath);
|
|
assert.ok(fs.existsSync(fullPath), `Script referenced in hooks.json does not exist on disk: ${fullPath}`);
|
|
const basename = path.basename(scriptPath);
|
|
assert.ok(
|
|
MANAGED_HOOKS.includes(basename),
|
|
`Script basename "${basename}" is not listed in hooks/managed-hooks-registry.cjs MANAGED_HOOKS`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('all six always-on hooks are wired', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
const REQUIRED_HOOKS = [
|
|
'gsd-check-update.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-worktree-path-guard.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-read-injection-scanner.js',
|
|
];
|
|
// Collect all basenames wired in hooks.json
|
|
const wiredBasenames = new Set();
|
|
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)/g;
|
|
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
|
for (const entry of eventEntries) {
|
|
for (const hook of entry.hooks) {
|
|
const matches = [...hook.command.matchAll(scriptPathRe)];
|
|
for (const m of matches) {
|
|
wiredBasenames.add(m[1]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const required of REQUIRED_HOOKS) {
|
|
assert.ok(wiredBasenames.has(required), `Required hook "${required}" is not wired in hooks/hooks.json`);
|
|
}
|
|
});
|
|
|
|
test('gsd-context-monitor.js entry has timeout === 10', (t) => {
|
|
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
|
let found = false;
|
|
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
|
for (const entry of eventEntries) {
|
|
for (const hook of entry.hooks) {
|
|
if (hook.command && hook.command.includes('gsd-context-monitor.js')) {
|
|
found = true;
|
|
assert.equal(hook.timeout, 10, 'gsd-context-monitor.js must have timeout === 10');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert.ok(found, 'gsd-context-monitor.js entry was not found in hooks.json');
|
|
});
|
|
});
|
|
|
|
// ─── Section C: Unconditional JSON schema gate + opportunistic CLI integration ──
|
|
//
|
|
// The `claude plugin validate --strict` binary is absent on CI, so Section C was
|
|
// previously SKIPPED there — the only full-schema gate never ran. This section
|
|
// replaces the skip-on-absent pattern with two tiers:
|
|
//
|
|
// C1 (UNCONDITIONAL) — Validate plugin.json against a snapshotted JSON schema
|
|
// fixture that captures the fields `--strict` requires. Runs on every
|
|
// platform, every CI job, every local run. A bug that removes `version`
|
|
// or changes `name` to an invalid form goes red immediately.
|
|
//
|
|
// C2 (OPPORTUNISTIC) — When the `claude` binary IS on PATH, also run
|
|
// `claude plugin validate <temp-plugin-root> --strict` as an end-to-end
|
|
// smoke test. This tier provides defence-in-depth for schema changes
|
|
// Claude Code may introduce that the fixture hasn't yet captured.
|
|
//
|
|
describe('C: plugin.json schema validation', () => {
|
|
|
|
const SCHEMA_FIXTURE_PATH = path.join(__dirname, 'fixtures', 'plugin-manifest-schema.json');
|
|
|
|
// ── C1: Unconditional structural gate ────────────────────────────────────────
|
|
//
|
|
// Validates plugin.json against the required fields from the snapshotted
|
|
// schema fixture (tests/fixtures/plugin-manifest-schema.json) using explicit
|
|
// structural assertions. This avoids a runtime dependency on `ajv` (which is
|
|
// only a transitive dep) while providing identical coverage for the fields that
|
|
// `claude plugin validate --strict` requires.
|
|
//
|
|
// Required fields and constraints are derived directly from SCHEMA_FIXTURE_PATH.
|
|
// If the fixture changes (new required field, new pattern), update this test too.
|
|
|
|
test('C1: plugin.json satisfies the snapshotted Claude Code plugin schema (unconditional)', () => {
|
|
assert.ok(
|
|
fs.existsSync(SCHEMA_FIXTURE_PATH),
|
|
`Schema fixture must exist: ${SCHEMA_FIXTURE_PATH}`
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(PLUGIN_JSON_PATH),
|
|
`.claude-plugin/plugin.json must exist: ${PLUGIN_JSON_PATH}`
|
|
);
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8'));
|
|
const schema = JSON.parse(fs.readFileSync(SCHEMA_FIXTURE_PATH, 'utf-8'));
|
|
const errors = [];
|
|
|
|
const schemaRequired = Array.isArray(schema.required) ? schema.required : [];
|
|
const schemaProps = (schema.properties && typeof schema.properties === 'object') ? schema.properties : {};
|
|
|
|
// Helper: assert a required field exists with the expected type.
|
|
function requireField(key, type) {
|
|
if (!(key in manifest)) {
|
|
errors.push(`"${key}" is required but missing`);
|
|
} else if (typeof manifest[key] !== type) {
|
|
errors.push(`"${key}" must be a ${type}, got ${typeof manifest[key]}`);
|
|
}
|
|
}
|
|
|
|
// Derive required fields and their types directly from the schema fixture.
|
|
// Each required field whose "properties" entry has a primitive "type" is
|
|
// checked via requireField; "object"-typed fields are handled below.
|
|
for (const key of schemaRequired) {
|
|
const propDef = schemaProps[key];
|
|
const fieldType = propDef && propDef.type;
|
|
if (fieldType === 'object') {
|
|
// Object fields are validated with deeper checks below.
|
|
continue;
|
|
}
|
|
requireField(key, fieldType || 'string');
|
|
}
|
|
|
|
// Validate "object"-typed required fields from the schema.
|
|
// For each such field, check existence, type, and any nested "required" sub-fields.
|
|
for (const key of schemaRequired) {
|
|
const propDef = schemaProps[key];
|
|
if (!propDef || propDef.type !== 'object') continue;
|
|
|
|
if (!(key in manifest)) {
|
|
errors.push(`"${key}" is required but missing`);
|
|
} else if (typeof manifest[key] !== 'object' || manifest[key] === null) {
|
|
errors.push(`"${key}" must be an object`);
|
|
} else {
|
|
// Validate nested required sub-fields declared in the schema.
|
|
const nestedRequired = Array.isArray(propDef.required) ? propDef.required : [];
|
|
const nestedProps = (propDef.properties && typeof propDef.properties === 'object') ? propDef.properties : {};
|
|
for (const subKey of nestedRequired) {
|
|
const subDef = nestedProps[subKey];
|
|
const subType = subDef && subDef.type;
|
|
if (!(subKey in manifest[key])) {
|
|
errors.push(`"${key}.${subKey}" is required but missing`);
|
|
} else if (subType && typeof manifest[key][subKey] !== subType) {
|
|
errors.push(`"${key}.${subKey}" must be a ${subType}, got ${typeof manifest[key][subKey]}`);
|
|
}
|
|
// minLength check for nested string sub-fields
|
|
if (subType === 'string' && subDef.minLength !== undefined) {
|
|
if (typeof manifest[key][subKey] === 'string' && manifest[key][subKey].length < subDef.minLength) {
|
|
errors.push(`"${key}.${subKey}" must have minLength ${subDef.minLength}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Derive pattern and minLength constraints from the schema fixture properties.
|
|
for (const key of schemaRequired) {
|
|
const propDef = schemaProps[key];
|
|
if (!propDef || propDef.type === 'object') continue;
|
|
const value = manifest[key];
|
|
|
|
if (propDef.pattern && typeof value === 'string') {
|
|
const re = new RegExp(propDef.pattern);
|
|
if (!re.test(value)) {
|
|
errors.push(`"${key}" must match ${propDef.pattern}, got "${value}"`);
|
|
}
|
|
}
|
|
|
|
if (propDef.minLength !== undefined && typeof value === 'string') {
|
|
if (value.length < propDef.minLength) {
|
|
errors.push(`"${key}" must have minLength ${propDef.minLength}, got length ${value.length}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
assert.fail(
|
|
`plugin.json fails structural validation against ${path.relative(ROOT, SCHEMA_FIXTURE_PATH)}:\n` +
|
|
errors.map(e => ` - ${e}`).join('\n') +
|
|
`\n\nFull manifest:\n${JSON.stringify(manifest, null, 2)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
// ── C2: Opportunistic CLI integration (skipped when claude not on PATH) ──────
|
|
|
|
const claudeAvailable = (() => {
|
|
try {
|
|
const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
|
|
return result.status === 0;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
test(
|
|
'C2: claude plugin validate --strict exits 0 (opportunistic — skip when claude not on PATH)',
|
|
{ skip: !claudeAvailable ? 'claude binary not on PATH' : false },
|
|
() => {
|
|
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plugin-validate-'));
|
|
try {
|
|
fs.mkdirSync(path.join(pluginRoot, '.claude-plugin'), { recursive: true });
|
|
fs.copyFileSync(PLUGIN_JSON_PATH, path.join(pluginRoot, '.claude-plugin', 'plugin.json'));
|
|
fs.symlinkSync(path.join(ROOT, 'commands'), path.join(pluginRoot, 'commands'), 'dir');
|
|
fs.symlinkSync(path.join(ROOT, 'hooks'), path.join(pluginRoot, 'hooks'), 'dir');
|
|
fs.symlinkSync(path.join(ROOT, 'skills'), path.join(pluginRoot, 'skills'), 'dir');
|
|
|
|
const result = spawnSync('claude', ['plugin', 'validate', pluginRoot, '--strict'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
timeout: 15000,
|
|
});
|
|
assert.equal(
|
|
result.status,
|
|
0,
|
|
`claude plugin validate ${pluginRoot} --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
|
);
|
|
} finally {
|
|
cleanup(pluginRoot);
|
|
}
|
|
}
|
|
);
|
|
});
|
|
|
|
// ─── Section D: Always-on hook contract (drift guard) ────────────────────────
|
|
describe('D: always-on hook contract drift guard', () => {
|
|
|
|
/**
|
|
* Parses hooks.json and builds a map:
|
|
* event -> matcher (or '' for no-matcher) -> [{script, timeout}]
|
|
*
|
|
* script: basename of the .js/.sh file referenced in the command string
|
|
* timeout: numeric value from hook.timeout, or undefined if absent
|
|
*/
|
|
function buildHookMap() {
|
|
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
|
const hooksConfig = JSON.parse(raw);
|
|
const scriptRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^\s"]+)/;
|
|
const map = {};
|
|
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
|
|
map[eventName] = map[eventName] || {};
|
|
for (const entry of eventEntries) {
|
|
const matcher = entry.matcher || '';
|
|
map[eventName][matcher] = map[eventName][matcher] || [];
|
|
for (const hook of entry.hooks) {
|
|
const m = hook.command.match(scriptRe);
|
|
if (m) {
|
|
map[eventName][matcher].push({
|
|
script: m[1],
|
|
timeout: hook.timeout,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
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 === 2,
|
|
`SessionStart no-matcher group must contain exactly two hooks; got: ${JSON.stringify(noMatcherHooks)}`
|
|
);
|
|
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)', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['PreToolUse'];
|
|
assert.ok(groups, 'PreToolUse must be present in hooks.json');
|
|
const hooks = groups['Write|Edit'];
|
|
assert.ok(
|
|
Array.isArray(hooks) && hooks.length === 2,
|
|
`PreToolUse Write|Edit must have exactly 2 hooks; got: ${JSON.stringify(hooks)}`
|
|
);
|
|
assert.equal(hooks[0].script, 'gsd-prompt-guard.js', 'first hook must be gsd-prompt-guard.js');
|
|
assert.equal(hooks[0].timeout, 5, 'gsd-prompt-guard.js must have timeout 5');
|
|
assert.equal(hooks[1].script, 'gsd-read-guard.js', 'second hook must be gsd-read-guard.js');
|
|
assert.equal(hooks[1].timeout, 5, 'gsd-read-guard.js must have timeout 5');
|
|
});
|
|
|
|
test('PreToolUse Write|Edit|MultiEdit group: gsd-worktree-path-guard.js (timeout 5)', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['PreToolUse'];
|
|
assert.ok(groups, 'PreToolUse must be present in hooks.json');
|
|
const hooks = groups['Write|Edit|MultiEdit'];
|
|
assert.ok(
|
|
Array.isArray(hooks) && hooks.length === 1,
|
|
`PreToolUse Write|Edit|MultiEdit must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
|
);
|
|
assert.equal(hooks[0].script, 'gsd-worktree-path-guard.js', 'hook must be gsd-worktree-path-guard.js');
|
|
assert.equal(hooks[0].timeout, 5, 'gsd-worktree-path-guard.js must have timeout 5');
|
|
});
|
|
|
|
test('PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task group: gsd-context-monitor.js (timeout 10)', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['PostToolUse'];
|
|
assert.ok(groups, 'PostToolUse must be present in hooks.json');
|
|
const hooks = groups['Bash|Edit|Write|MultiEdit|Agent|Task'];
|
|
assert.ok(
|
|
Array.isArray(hooks) && hooks.length === 1,
|
|
`PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
|
);
|
|
assert.equal(hooks[0].script, 'gsd-context-monitor.js', 'hook must be gsd-context-monitor.js');
|
|
assert.equal(hooks[0].timeout, 10, 'gsd-context-monitor.js must have timeout 10');
|
|
});
|
|
|
|
test('PostToolUse Read|WebFetch|WebSearch group: gsd-read-injection-scanner.js (timeout 5)', () => {
|
|
const map = buildHookMap();
|
|
const groups = map['PostToolUse'];
|
|
assert.ok(groups, 'PostToolUse must be present in hooks.json');
|
|
// #1577: the injection scanner now also covers WebFetch/WebSearch ingress,
|
|
// so the matcher is the combined "Read|WebFetch|WebSearch" group.
|
|
const hooks = groups['Read|WebFetch|WebSearch'];
|
|
assert.ok(
|
|
Array.isArray(hooks) && hooks.length === 1,
|
|
`PostToolUse Read|WebFetch|WebSearch must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
|
);
|
|
assert.equal(hooks[0].script, 'gsd-read-injection-scanner.js', 'hook must be gsd-read-injection-scanner.js');
|
|
assert.equal(hooks[0].timeout, 5, 'gsd-read-injection-scanner.js must have timeout 5');
|
|
});
|
|
});
|
|
|
|
// ─── Section E: Config-gated hooks must be absent from hooks.json ─────────────
|
|
describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => {
|
|
|
|
const CONFIG_GATED_HOOKS = [
|
|
'gsd-workflow-guard.js',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-graphify-update.sh',
|
|
'gsd-session-state.sh',
|
|
'gsd-phase-boundary.sh',
|
|
'gsd-update-banner.js',
|
|
'gsd-statusline.js',
|
|
'gsd-check-update-worker.js',
|
|
];
|
|
|
|
test('none of the config-gated hook basenames appear in hooks.json command strings', () => {
|
|
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
|
// Check raw text — simple and resistant to structure changes
|
|
for (const hookBasename of CONFIG_GATED_HOOKS) {
|
|
assert.ok(
|
|
!raw.includes(hookBasename),
|
|
`Config-gated hook "${hookBasename}" must NOT appear in hooks/hooks.json ` +
|
|
`(it is opt-in and must not run unconditionally on the plugin path)`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── 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'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Section H: skills surface projection (#1596 — Phase B-provide) ──────────
|
|
//
|
|
// ADR-766 originally projected commands + hooks but NOT skills. Phase B-provide
|
|
// (#1596) adds a build-generated `skills/` dir + a `skills` manifest field so
|
|
// plugin-installed GSD exposes `gsd-core:<skill>` the native Claude Code way.
|
|
// The skills are generated from `commands/gsd/*.md` by
|
|
// `scripts/gen-plugin-skills.cjs` using `convertClaudeCommandToClaudeSkill`.
|
|
describe('H: skills surface projection (#1596)', () => {
|
|
const SKILLS_DIR = path.resolve(ROOT, 'skills');
|
|
|
|
test('plugin.json declares skills: "./skills/"', () => {
|
|
const manifest = JSON.parse(fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8'));
|
|
assert.equal(
|
|
manifest.skills, './skills/',
|
|
'plugin.json must declare "skills": "./skills/" so Claude Code discovers plugin skills (#1596)'
|
|
);
|
|
});
|
|
|
|
test('skills/ dir exists with at least one gsd-*/SKILL.md', () => {
|
|
assert.ok(fs.existsSync(SKILLS_DIR), `skills/ dir must exist (run: npm run gen:plugin-skills -- --write): ${SKILLS_DIR}`);
|
|
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
|
|
const skillDirs = entries.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
|
assert.ok(skillDirs.length > 0, 'skills/ must contain at least one gsd-*/ directory');
|
|
// Each must have a SKILL.md
|
|
for (const dir of skillDirs) {
|
|
const skillMd = path.join(SKILLS_DIR, dir.name, 'SKILL.md');
|
|
assert.ok(fs.existsSync(skillMd), `${dir.name}/SKILL.md must exist`);
|
|
}
|
|
});
|
|
|
|
test('every generated SKILL.md has name: and description: frontmatter', () => {
|
|
assert.ok(fs.existsSync(SKILLS_DIR), 'skills/ must exist');
|
|
const skillDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
|
assert.ok(skillDirs.length > 0, 'must have at least one skill dir');
|
|
for (const dir of skillDirs) {
|
|
const raw = fs.readFileSync(path.join(SKILLS_DIR, dir.name, 'SKILL.md'), 'utf-8');
|
|
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
assert.ok(fmMatch, `${dir.name}/SKILL.md must have frontmatter`);
|
|
const fm = fmMatch[1];
|
|
assert.ok(/^\s*name:\s*\S/m.test(fm), `${dir.name}/SKILL.md frontmatter must have a name: field`);
|
|
assert.ok(/^\s*description:\s*\S/m.test(fm), `${dir.name}/SKILL.md frontmatter must have a description: field`);
|
|
}
|
|
});
|
|
|
|
test('parity: one skill dir per command file (DEFECT.GENERATIVE-FIX)', () => {
|
|
const commandsDir = path.resolve(ROOT, 'commands', 'gsd');
|
|
const commandFiles = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
|
const skillDirs = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
|
assert.equal(
|
|
skillDirs.length, commandFiles.length,
|
|
`skills/gsd-*/ count (${skillDirs.length}) must equal commands/gsd/*.md count (${commandFiles.length}). ` +
|
|
`Run: npm run gen:plugin-skills -- --write`
|
|
);
|
|
});
|
|
});
|