* fix(#1160): resolve capability surface from installed skill layouts In a global skills-runtime install (e.g. Codex at ~/.codex), gsd-tools.cjs runs from <configDir>/gsd-core/bin/ and the commands/gsd source tree is absent — only <configDir>/skills/gsd-<stem>/SKILL.md files exist. _resolveCommandsGsdDir() returned a path that does not exist there, so loadSkillsManifest returned an empty Map. resolveSurface then materialised the '*' (full) profile sentinel by enumerating that empty manifest → empty surfaced Set → every capability reported surfaced=false/enabled=false regardless of project config. As a result `loop render-hooks verify:post` returned activeHooks:[] even with workflow.security_enforcement and workflow.nyquist_validation enabled, silently disabling the security and Nyquist gates. Fix: add _loadInstalledSkillsManifest(configDir) that scans configDir/skills/ for gsd-<stem>/SKILL.md dirs and builds the same Map shape, and _resolveManifest(commandsGsdDir, configDir) that prefers the source tree when present (preserving repo-checkout behaviour) and falls back to the installed skills layout otherwise. Both resolveCapabilityRuntimeState call sites use _resolveManifest. Both helpers are exported for direct unit-testing. Tests: capability-state.test.cjs gains a faithful installed-runtime e2e block that copies gsd-core/bin + scripts + package.json into a temp install root with no reachable commands/gsd, then runs the real gsd-tools.cjs against an installed skills/ layout. It asserts capability state reports security & nyquist enabled and verify:post includes security->secure-phase and nyquist->validate-phase; a disabled-config negative confirms no over-activation. This block FAILS before the fix (activeHooks:[]) and PASSES after. Plus unit coverage for the two new helpers and the empty-surface pre-fix scenario. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(changeset): backfill PR number 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:
5
.changeset/1160-installed-runtime-capability-surface.md
Normal file
5
.changeset/1160-installed-runtime-capability-surface.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1206
|
||||
---
|
||||
**Installed runtimes no longer silently disable `verify:post` gates** — in a global skills-runtime install (e.g. Codex at `~/.codex`), the `commands/gsd` source tree is absent, so capability-state resolved an empty skill manifest. The full-profile `*` sentinel then materialized to an empty surfaced set, marking every capability `surfaced=false` → `enabled=false`. The result: `gsd-tools loop render-hooks verify:post` returned `activeHooks: []` even with `security_enforcement` and `nyquist_validation` enabled, so the security and Nyquist gates never fired. Capability-state now falls back to the installed `<configDir>/skills/gsd-*/SKILL.md` layout when the source tree is unreachable, so `verify:post` again includes `security -> secure-phase` and `nyquist -> validate-phase`. (#1206)
|
||||
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import core = require('./core.cjs');
|
||||
@@ -44,7 +45,7 @@ const { loadConfig } = configLoaderMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import installProfilesMod = require('./install-profiles.cjs');
|
||||
const { readActiveProfile, loadSkillsManifest, resolveProfile } = installProfilesMod;
|
||||
const { readActiveProfile, loadSkillsManifest, resolveProfile, parseRequires } = installProfilesMod;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import surfaceMod = require('./surface.cjs');
|
||||
@@ -278,6 +279,88 @@ function _resolveCommandsGsdDir(): string {
|
||||
return path.join(repoRoot, 'commands', 'gsd');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a skill dependency manifest from an INSTALLED runtime's skills directory.
|
||||
*
|
||||
* In an installed runtime (e.g. Codex at ~/.codex), gsd skills live as
|
||||
* configDir/skills/gsd-STEM/SKILL.md. There is no commands/gsd source tree.
|
||||
* This function scans that installed layout and builds the same
|
||||
* Map shape that loadSkillsManifest produces from sources.
|
||||
*
|
||||
* Stem extraction: a directory named gsd-secure-phase maps to stem secure-phase.
|
||||
* Only directories whose names start with gsd- are included so user-created
|
||||
* skills (without the gsd- prefix) are not accidentally pulled in.
|
||||
*
|
||||
* The requires: field is parsed via the shared parseRequires helper (the same
|
||||
* parser loadSkillsManifest uses), so the two paths cannot drift.
|
||||
*
|
||||
* Returns an empty Map when the skills dir does not exist.
|
||||
*/
|
||||
function _loadInstalledSkillsManifest(configDir: string): Map<string, string[]> {
|
||||
const manifest = new Map<string, string[]>();
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
if (!fs.existsSync(skillsDir)) return manifest;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return manifest;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (!entry.name.startsWith('gsd-')) continue;
|
||||
// Strip the 'gsd-' prefix to get the skill stem
|
||||
const stem = entry.name.slice(4); // 'gsd-'.length === 4
|
||||
if (!stem) continue;
|
||||
const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md');
|
||||
// Parity with loadSkillsManifest: a stem exists only when its artifact
|
||||
// file is present. loadSkillsManifest registers a stem per .md FILE (and
|
||||
// tolerates an unreadable file as []), but never invents a stem for which
|
||||
// no file exists. Mirror that here: a stale gsd-<stem>/ directory with no
|
||||
// SKILL.md must NOT register the stem — otherwise the capability would be
|
||||
// wrongly reported surfaced/enabled and a verify:post hook would render
|
||||
// for a skill that cannot run.
|
||||
if (!fs.existsSync(skillMdPath)) continue;
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(skillMdPath, 'utf8');
|
||||
} catch {
|
||||
// SKILL.md present but unreadable — register with no deps (parity with
|
||||
// loadSkillsManifest's readFileSync catch branch).
|
||||
}
|
||||
// Parse requires: via the SAME shared parser loadSkillsManifest uses, so
|
||||
// installed-runtime dependency resolution can never silently diverge from
|
||||
// the source-tree path (single source of truth — no duplicated regex).
|
||||
manifest.set(stem, content ? parseRequires(content) : []);
|
||||
// Mirror loadSkillsManifest's Map shape: it always sets a companion
|
||||
// `_calls_agents_<stem>` key. Installed SKILL.md bodies carry no
|
||||
// recoverable agent-call refs, so [] (the no-agents case) keeps the two
|
||||
// manifest shapes identical and prevents undefined-vs-[] drift for any
|
||||
// consumer that reads the agent-refs companion key.
|
||||
manifest.set(`_calls_agents_${stem}`, []);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the skill dependency manifest for capability-state resolution.
|
||||
*
|
||||
* Resolution order (fixes #1160 — installed-runtime capability surface):
|
||||
* 1. If commandsGsdDir exists, load from source (repo-checkout behavior).
|
||||
* 2. Otherwise, fall back to installed skills at configDir/skills/gsd-[stem]/SKILL.md.
|
||||
*
|
||||
* In an installed runtime the commands/gsd source tree is absent; only the
|
||||
* skills/ layout exists. Returning an empty manifest caused resolveSurface to
|
||||
* materialise the full-sentinel to an empty Set, making every capability appear
|
||||
* unsurfaced even when the skill was physically installed.
|
||||
*/
|
||||
function _resolveManifest(commandsGsdDir: string, configDir: string): Map<string, string[]> {
|
||||
if (fs.existsSync(commandsGsdDir)) {
|
||||
return loadSkillsManifest(commandsGsdDir);
|
||||
}
|
||||
return _loadInstalledSkillsManifest(configDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command entry point: resolve install profile, surface, and config; compute
|
||||
* capability state; emit the envelope via core.output.
|
||||
@@ -357,7 +440,9 @@ function resolveCapabilityRuntimeState(
|
||||
let installedSkills: Set<string> | '*';
|
||||
try {
|
||||
const commandsGsdDir = _resolveCommandsGsdDir();
|
||||
const manifest = loadSkillsManifest(commandsGsdDir);
|
||||
// Fix #1160: use _resolveManifest so installed-runtime layouts (where
|
||||
// commands/gsd is absent) fall back to <configDir>/skills/gsd-*/SKILL.md.
|
||||
const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
|
||||
const profileName = readActiveProfile(resolvedConfigDir) ?? 'full';
|
||||
const resolvedInstall = resolveProfile({
|
||||
modes: profileName.split(',').map((s: string) => s.trim()),
|
||||
@@ -377,7 +462,9 @@ function resolveCapabilityRuntimeState(
|
||||
let surfacedSkills: Set<string>;
|
||||
try {
|
||||
const commandsGsdDir = _resolveCommandsGsdDir();
|
||||
const manifest = loadSkillsManifest(commandsGsdDir);
|
||||
// Fix #1160: use _resolveManifest so installed-runtime layouts (where
|
||||
// commands/gsd is absent) fall back to <configDir>/skills/gsd-*/SKILL.md.
|
||||
const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
|
||||
const surfaceResult = resolveSurface(resolvedConfigDir, manifest, undefined, registry);
|
||||
// resolveSurface returns { name, skills: Set<string>, agents: Set<string> }
|
||||
// (always a concrete Set — full profile is materialized)
|
||||
@@ -452,5 +539,7 @@ export = {
|
||||
cmdCapabilityState,
|
||||
// Exported for tests
|
||||
_resolveCommandsGsdDir,
|
||||
_loadInstalledSkillsManifest,
|
||||
_resolveManifest,
|
||||
_isSafePropKey,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@ const { cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
resolveCapabilityState,
|
||||
_isSafePropKey,
|
||||
_loadInstalledSkillsManifest,
|
||||
_resolveManifest,
|
||||
} = require('../gsd-core/bin/lib/capability-state.cjs');
|
||||
|
||||
// The real capability registry
|
||||
@@ -743,3 +745,435 @@ describe('cmdCapabilityState — end-to-end via gsd-tools CLI', () => {
|
||||
assert.strictEqual(planPreStep.active, false, 'effective hook activity must match workflow dispatch');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── regressions: installed-runtime capability surface (#1160) ────────────────
|
||||
//
|
||||
// In an installed runtime (e.g. Codex) the commands/gsd source tree is absent.
|
||||
// Only <configDir>/skills/gsd-*/SKILL.md files exist. Prior to this fix,
|
||||
// loadSkillsManifest returned an empty map → resolveSurface materialized '*'
|
||||
// to an empty Set → security.enabled=false / nyquist.enabled=false even when
|
||||
// the project config had security_enforcement=true / nyquist_validation=true.
|
||||
//
|
||||
// These tests directly exercise the new _loadInstalledSkillsManifest and
|
||||
// _resolveManifest functions with a non-existent commandsGsdDir path so they
|
||||
// FAIL before the fix and PASS after, regardless of whether commands/gsd
|
||||
// happens to exist in the current checkout.
|
||||
|
||||
describe('regressions: installed-runtime capability surface (#1160)', () => {
|
||||
// Minimal valid SKILL.md content (frontmatter only — matches what install emits)
|
||||
function makeSkillMd(stem) {
|
||||
return [
|
||||
'---',
|
||||
`name: gsd:${stem}`,
|
||||
`description: ${stem} skill`,
|
||||
'argument-hint: "[phase number]"',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
'requires: [phase]',
|
||||
'---',
|
||||
'Execute end-to-end.',
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ── Unit tests for _loadInstalledSkillsManifest ──────────────────────────────
|
||||
|
||||
test('_loadInstalledSkillsManifest: returns empty map when skills dir absent', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-empty-'));
|
||||
try {
|
||||
const manifest = _loadInstalledSkillsManifest(tmpDir);
|
||||
assert.ok(manifest instanceof Map, 'should return a Map');
|
||||
assert.strictEqual(manifest.size, 0, 'should be empty when no skills/ dir');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('_loadInstalledSkillsManifest: scans gsd-<stem>/SKILL.md dirs and produces stems', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-scan-'));
|
||||
try {
|
||||
// Create installed skill dirs
|
||||
const secureDir = path.join(tmpDir, 'skills', 'gsd-secure-phase');
|
||||
const validateDir = path.join(tmpDir, 'skills', 'gsd-validate-phase');
|
||||
fs.mkdirSync(secureDir, { recursive: true });
|
||||
fs.mkdirSync(validateDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8');
|
||||
fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8');
|
||||
// Add a non-gsd- dir that should be ignored
|
||||
fs.mkdirSync(path.join(tmpDir, 'skills', 'user-custom'), { recursive: true });
|
||||
|
||||
const manifest = _loadInstalledSkillsManifest(tmpDir);
|
||||
assert.ok(manifest instanceof Map);
|
||||
assert.ok(manifest.has('secure-phase'), 'should have secure-phase stem');
|
||||
assert.ok(manifest.has('validate-phase'), 'should have validate-phase stem');
|
||||
assert.ok(!manifest.has('user-custom'), 'non-gsd- dir must not appear');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('_loadInstalledSkillsManifest: parses requires from SKILL.md frontmatter', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-req-'));
|
||||
try {
|
||||
const skillDir = path.join(tmpDir, 'skills', 'gsd-my-skill');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: gsd:my-skill\nrequires: [dep-a, dep-b]\n---\nbody\n',
|
||||
'utf8',
|
||||
);
|
||||
const manifest = _loadInstalledSkillsManifest(tmpDir);
|
||||
assert.deepStrictEqual(manifest.get('my-skill'), ['dep-a', 'dep-b']);
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('_loadInstalledSkillsManifest: directory with no SKILL.md is NOT registered (parity with loadSkillsManifest)', () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-ism-nodoc-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tmpDir, 'skills', 'gsd-nodoc'), { recursive: true });
|
||||
// No SKILL.md written — a stale/empty skill dir must not invent a stem,
|
||||
// mirroring loadSkillsManifest which only registers stems for files that exist.
|
||||
const manifest = _loadInstalledSkillsManifest(tmpDir);
|
||||
assert.ok(!manifest.has('nodoc'), 'stem must NOT be registered when SKILL.md is absent');
|
||||
assert.ok(!manifest.has('_calls_agents_nodoc'), 'companion agents key must also be absent');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Unit tests for _resolveManifest ─────────────────────────────────────────
|
||||
|
||||
test('_resolveManifest: uses commandsGsdDir when it exists', () => {
|
||||
const realCommandsGsdDir = path.resolve(__dirname, '..', 'commands', 'gsd');
|
||||
if (!fs.existsSync(realCommandsGsdDir)) {
|
||||
// Skip if not in a repo checkout
|
||||
return;
|
||||
}
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-src-'));
|
||||
try {
|
||||
// No skills/ dir — if _resolveManifest uses source, it returns real skills
|
||||
const manifest = _resolveManifest(realCommandsGsdDir, tmpDir);
|
||||
assert.ok(manifest instanceof Map);
|
||||
// The real commands/gsd has many skills; manifest should be non-empty
|
||||
assert.ok(manifest.size > 0, 'should load skills from real source dir');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('_resolveManifest: falls back to installed skills when commandsGsdDir absent', () => {
|
||||
const nonExistentDir = path.join(os.tmpdir(), 'cap-rm-nonexistent-' + Date.now());
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rm-installed-'));
|
||||
try {
|
||||
// Create installed skill layout under tmpDir
|
||||
const secureDir = path.join(tmpDir, 'skills', 'gsd-secure-phase');
|
||||
const validateDir = path.join(tmpDir, 'skills', 'gsd-validate-phase');
|
||||
fs.mkdirSync(secureDir, { recursive: true });
|
||||
fs.mkdirSync(validateDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8');
|
||||
fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8');
|
||||
|
||||
const manifest = _resolveManifest(nonExistentDir, tmpDir);
|
||||
assert.ok(manifest instanceof Map);
|
||||
assert.ok(manifest.has('secure-phase'), 'must have secure-phase from installed skills');
|
||||
assert.ok(manifest.has('validate-phase'), 'must have validate-phase from installed skills');
|
||||
} finally {
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Integration tests: capability state with installed-layout config dir ────
|
||||
// These tests use a config dir that has NO .gsd-source and where _resolveCommandsGsdDir
|
||||
// would return the real repo path. To force the installed-path, we call
|
||||
// resolveCapabilityState directly with manifests derived from _resolveManifest
|
||||
// using a non-existent commandsGsdDir.
|
||||
|
||||
test('resolveCapabilityState: security enabled when secure-phase in installedSkills+surfacedSkills', () => {
|
||||
const securitySkills = ['secure-phase'];
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: new Set(securitySkills),
|
||||
surfacedSkills: new Set(securitySkills),
|
||||
config: { workflow: { security_enforcement: true } },
|
||||
});
|
||||
const secCap = result.capabilities.find((c) => c.id === 'security');
|
||||
assert.ok(secCap, 'security capability must be present');
|
||||
assert.strictEqual(secCap.installed, true, 'secure-phase in installedSkills → installed');
|
||||
assert.strictEqual(secCap.surfaced, true, 'secure-phase in surfacedSkills → surfaced');
|
||||
assert.strictEqual(secCap.enabled, true, 'installed+surfaced → enabled');
|
||||
});
|
||||
|
||||
test('resolveCapabilityState: security NOT enabled when surfacedSkills empty (pre-fix scenario)', () => {
|
||||
// Simulates the pre-fix behavior: manifest empty → surface materializes to empty Set
|
||||
const result = resolveCapabilityState({
|
||||
registry: realRegistry,
|
||||
installedSkills: '*', // full profile → installed=true
|
||||
surfacedSkills: new Set(), // empty set (what empty manifest causes)
|
||||
config: { workflow: { security_enforcement: true } },
|
||||
});
|
||||
const secCap = result.capabilities.find((c) => c.id === 'security');
|
||||
assert.ok(secCap, 'security capability must be present');
|
||||
assert.strictEqual(secCap.installed, true);
|
||||
assert.strictEqual(secCap.surfaced, false, 'empty surfacedSkills → surfaced=false (pre-fix bug)');
|
||||
assert.strictEqual(secCap.enabled, false, 'surfaced=false → enabled=false (pre-fix bug)');
|
||||
});
|
||||
|
||||
// ── End-to-end CLI tests: capability state + loop render-hooks ───────────────
|
||||
|
||||
describe('end-to-end CLI with installed skill layout', () => {
|
||||
let tmpInstalledConfigDir;
|
||||
let tmpInstalledProjectDir;
|
||||
|
||||
before(() => {
|
||||
tmpInstalledConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installed-'));
|
||||
fs.writeFileSync(path.join(tmpInstalledConfigDir, '.gsd-profile'), 'full\n', 'utf8');
|
||||
const secureDir = path.join(tmpInstalledConfigDir, 'skills', 'gsd-secure-phase');
|
||||
const validateDir = path.join(tmpInstalledConfigDir, 'skills', 'gsd-validate-phase');
|
||||
fs.mkdirSync(secureDir, { recursive: true });
|
||||
fs.mkdirSync(validateDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(secureDir, 'SKILL.md'), makeSkillMd('secure-phase'), 'utf8');
|
||||
fs.writeFileSync(path.join(validateDir, 'SKILL.md'), makeSkillMd('validate-phase'), 'utf8');
|
||||
|
||||
tmpInstalledProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installed-proj-'));
|
||||
fs.mkdirSync(path.join(tmpInstalledProjectDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpInstalledProjectDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { security_enforcement: true, nyquist_validation: true } }),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpInstalledConfigDir);
|
||||
cleanup(tmpInstalledProjectDir);
|
||||
});
|
||||
|
||||
test('security capability: enabled=true in installed runtime with security_enforcement=true', () => {
|
||||
const result = runCapabilityState(tmpInstalledProjectDir, tmpInstalledConfigDir);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
const secCap = envelope.capabilities.find((c) => c.id === 'security');
|
||||
assert.ok(secCap, 'security capability must be present in output');
|
||||
assert.strictEqual(secCap.installed, true, 'security skill secure-phase is installed');
|
||||
assert.strictEqual(secCap.surfaced, true, 'security skill secure-phase is surfaced (full profile)');
|
||||
assert.strictEqual(secCap.enabled, true, 'security capability must be enabled');
|
||||
});
|
||||
|
||||
test('nyquist capability: enabled=true in installed runtime with nyquist_validation=true', () => {
|
||||
const result = runCapabilityState(tmpInstalledProjectDir, tmpInstalledConfigDir);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
const nyqCap = envelope.capabilities.find((c) => c.id === 'nyquist');
|
||||
assert.ok(nyqCap, 'nyquist capability must be present in output');
|
||||
assert.strictEqual(nyqCap.installed, true, 'nyquist skill validate-phase is installed');
|
||||
assert.strictEqual(nyqCap.surfaced, true, 'nyquist skill validate-phase is surfaced (full profile)');
|
||||
assert.strictEqual(nyqCap.enabled, true, 'nyquist capability must be enabled');
|
||||
});
|
||||
|
||||
test('security hook at verify:post is active in installed runtime', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
gsdToolsPath,
|
||||
'loop', 'render-hooks', 'verify:post',
|
||||
'--config-dir', tmpInstalledConfigDir,
|
||||
'--cwd', tmpInstalledProjectDir,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'verify:post');
|
||||
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
||||
const securityHook = envelope.activeHooks.find(
|
||||
(h) => h.capId === 'security' && h.kind === 'step',
|
||||
);
|
||||
assert.ok(
|
||||
securityHook,
|
||||
'verify:post must include security step hook when security_enforcement=true. Got: ' +
|
||||
JSON.stringify(envelope.activeHooks),
|
||||
);
|
||||
assert.ok(
|
||||
securityHook.ref && securityHook.ref.skill === 'secure-phase',
|
||||
'security hook ref.skill must be secure-phase',
|
||||
);
|
||||
});
|
||||
|
||||
test('nyquist hook at verify:post is active in installed runtime', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
gsdToolsPath,
|
||||
'loop', 'render-hooks', 'verify:post',
|
||||
'--config-dir', tmpInstalledConfigDir,
|
||||
'--cwd', tmpInstalledProjectDir,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 15000 },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
const nyquistHook = envelope.activeHooks.find(
|
||||
(h) => h.capId === 'nyquist' && h.kind === 'step',
|
||||
);
|
||||
assert.ok(
|
||||
nyquistHook,
|
||||
'verify:post must include nyquist step hook when nyquist_validation=true. Got: ' +
|
||||
JSON.stringify(envelope.activeHooks),
|
||||
);
|
||||
assert.ok(
|
||||
nyquistHook.ref && nyquistHook.ref.skill === 'validate-phase',
|
||||
'nyquist hook ref.skill must be validate-phase',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── TRUE installed-runtime layout: gsd-tools runs where commands/gsd is unreachable ──
|
||||
// The block above runs the repo's gsd-tools.cjs, where commands/gsd source IS
|
||||
// reachable by walk-up, so it cannot exercise the bug. This block copies the
|
||||
// runtime executable tree (gsd-core/bin + scripts + package.json) into a temp
|
||||
// install root that has NO commands/ sibling — faithfully reproducing a global
|
||||
// skills-runtime install (e.g. Codex at ~/.codex). There, the source manifest
|
||||
// is genuinely empty, so pre-fix the '*' profile materialized to an empty
|
||||
// surfaced set → enabled=false → verify:post activeHooks: []. This test FAILS
|
||||
// before the fix and PASSES after.
|
||||
describe('true installed layout (commands/gsd unreachable)', () => {
|
||||
let installRoot;
|
||||
let installedConfigDir;
|
||||
let installedProjectDir;
|
||||
let installedGsdTools;
|
||||
|
||||
before(() => {
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
// 1. Faithful install root: copy the executable runtime WITHOUT commands/.
|
||||
installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-installroot-'));
|
||||
fs.mkdirSync(path.join(installRoot, 'gsd-core'), { recursive: true });
|
||||
fs.cpSync(
|
||||
path.join(repoRoot, 'gsd-core', 'bin'),
|
||||
path.join(installRoot, 'gsd-core', 'bin'),
|
||||
{ recursive: true },
|
||||
);
|
||||
fs.cpSync(
|
||||
path.join(repoRoot, 'scripts'),
|
||||
path.join(installRoot, 'scripts'),
|
||||
{ recursive: true },
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(repoRoot, 'package.json'),
|
||||
path.join(installRoot, 'package.json'),
|
||||
);
|
||||
installedGsdTools = path.join(installRoot, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
|
||||
// 2. Installed runtime config dir: full profile + skills/gsd-*/SKILL.md only.
|
||||
installedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instcfg-'));
|
||||
fs.writeFileSync(path.join(installedConfigDir, '.gsd-profile'), 'full\n', 'utf8');
|
||||
for (const stem of ['secure-phase', 'validate-phase']) {
|
||||
const skillDir = path.join(installedConfigDir, 'skills', `gsd-${stem}`);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), makeSkillMd(stem), 'utf8');
|
||||
}
|
||||
|
||||
// 3. Project enabling both gates.
|
||||
installedProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instproj-'));
|
||||
fs.mkdirSync(path.join(installedProjectDir, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(installedProjectDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { security_enforcement: true, nyquist_validation: true } }),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(installRoot);
|
||||
cleanup(installedConfigDir);
|
||||
cleanup(installedProjectDir);
|
||||
});
|
||||
|
||||
test('commands/gsd is genuinely unreachable from the installed gsd-tools', () => {
|
||||
// Sanity: no commands/gsd anywhere under the install root.
|
||||
const probe = path.join(installRoot, 'commands', 'gsd');
|
||||
assert.strictEqual(fs.existsSync(probe), false, 'install root must have no commands/gsd');
|
||||
});
|
||||
|
||||
test('capability state: security & nyquist enabled in true installed runtime', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
installedGsdTools,
|
||||
'capability', 'state',
|
||||
'--config-dir', installedConfigDir,
|
||||
'--cwd', installedProjectDir,
|
||||
'--raw',
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 20000 },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout);
|
||||
const secCap = envelope.capabilities.find((c) => c.id === 'security');
|
||||
const nyqCap = envelope.capabilities.find((c) => c.id === 'nyquist');
|
||||
assert.ok(secCap && nyqCap, 'security and nyquist capabilities must be present');
|
||||
assert.strictEqual(secCap.surfaced, true, 'security surfaced from installed skills (pre-fix: false)');
|
||||
assert.strictEqual(secCap.enabled, true, 'security enabled in installed runtime (pre-fix: false)');
|
||||
assert.strictEqual(nyqCap.surfaced, true, 'nyquist surfaced from installed skills (pre-fix: false)');
|
||||
assert.strictEqual(nyqCap.enabled, true, 'nyquist enabled in installed runtime (pre-fix: false)');
|
||||
});
|
||||
|
||||
test('loop render-hooks verify:post: includes security & nyquist in true installed runtime', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
installedGsdTools,
|
||||
'loop', 'render-hooks', 'verify:post',
|
||||
'--config-dir', installedConfigDir,
|
||||
'--cwd', installedProjectDir,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 20000 },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
assert.strictEqual(envelope.point, 'verify:post');
|
||||
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
|
||||
const sec = envelope.activeHooks.find((h) => h.capId === 'security' && h.kind === 'step');
|
||||
const nyq = envelope.activeHooks.find((h) => h.capId === 'nyquist' && h.kind === 'step');
|
||||
assert.ok(
|
||||
sec && sec.ref && sec.ref.skill === 'secure-phase',
|
||||
'verify:post must include security -> secure-phase (pre-fix: activeHooks was []). Got: ' + JSON.stringify(envelope.activeHooks),
|
||||
);
|
||||
assert.ok(
|
||||
nyq && nyq.ref && nyq.ref.skill === 'validate-phase',
|
||||
'verify:post must include nyquist -> validate-phase (pre-fix: activeHooks was []). Got: ' + JSON.stringify(envelope.activeHooks),
|
||||
);
|
||||
});
|
||||
|
||||
test('negative: when both gates disabled, hooks stay inactive (no over-activation)', () => {
|
||||
const disabledProj = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-state-instproj-off-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(disabledProj, '.planning'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(disabledProj, '.planning', 'config.json'),
|
||||
JSON.stringify({ workflow: { security_enforcement: false, nyquist_validation: false } }),
|
||||
'utf8',
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
installedGsdTools,
|
||||
'loop', 'render-hooks', 'verify:post',
|
||||
'--config-dir', installedConfigDir,
|
||||
'--cwd', disabledProj,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 20000 },
|
||||
);
|
||||
assert.strictEqual(result.status, 0, `gsd-tools exited ${result.status}:\nstderr: ${result.stderr}`);
|
||||
const envelope = JSON.parse(result.stdout.trim());
|
||||
const sec = (envelope.activeHooks || []).find((h) => h.capId === 'security' && h.kind === 'step');
|
||||
const nyq = (envelope.activeHooks || []).find((h) => h.capId === 'nyquist' && h.kind === 'step');
|
||||
assert.ok(!sec, 'security step must NOT be active when security_enforcement=false');
|
||||
assert.ok(!nyq, 'nyquist step must NOT be active when nyquist_validation=false');
|
||||
} finally {
|
||||
cleanup(disabledProj);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user