Files
msd-core/tests/capability-cli.test.cjs

759 lines
43 KiB
JavaScript

'use strict';
/**
* capability-cli.test.cjs — behavioral tests for the `gsd capability` MANAGEMENT CLI
* (ADR-1244 D5/D6): install / update / remove / list / disable / enable wired in
* gsd-tools.cjs `case 'capability'` to the Phase-4 lifecycle + Phase-3 ledger.
*
* These exercise the REAL CLI end-to-end via runGsdTools (subprocess), the REAL
* source resolver (local-path kind — no network), and a GSD_HOME-sandboxed global
* scope so no developer state is touched. They are the contract the reference doc
* (docs/reference/gsd-capability-command.md) is verified against.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { runGsdTools, cleanup } = require('./helpers.cjs');
// ─── Fixtures ───────────────────────────────────────────────────────────────
const tmps = [];
function tmpDir(prefix) {
const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tmps.push(d);
return d;
}
test.after(() => { for (const d of tmps) cleanup(d); });
/** A GSD_HOME-sandboxed env that also neutralizes ambient GSD_ vars (test hermeticity). */
function scopeEnv(home) {
return { GSD_HOME: home, GSD_WORKSTREAM: '', GSD_PROJECT: '' };
}
/** A cwd with a .planning/ root so findProjectRoot resolves cleanly. */
function makeCwd() {
const cwd = tmpDir('cap-cli-cwd-');
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), '{}');
return cwd;
}
/** A project cwd whose config carries a given capabilities.strict_known_registries value. */
function makeCwdWithStrict(strictValue) {
const cwd = tmpDir('cap-cli-cwd-');
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(cwd, '.planning', 'config.json'),
JSON.stringify({ capabilities: { strict_known_registries: strictValue } }),
);
return cwd;
}
/**
* Write a conformant local capability source dir and return its absolute path
* (usable directly as an install <spec>). Declarative by default; pass `hooks`
* (with materialized scripts) to make it an executable surface requiring consent.
*/
function writeCapSource(id, { version = '1.0.0', hooks = [], engines, mcp } = {}) {
const src = tmpDir(`cap-cli-src-${id}-`);
const cap = {
id,
role: 'feature',
version,
title: id,
description: 'test capability',
tier: 'standard',
requires: [],
runtimeCompat: { supported: ['*'], unsupported: [] },
skills: [],
agents: [],
hooks,
config: {},
steps: [],
contributions: [],
gates: [],
};
if (engines) cap.engines = engines;
if (mcp) cap.mcpServers = mcp; // object map { name: {command, ...} } — an executable surface
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
for (const h of hooks) {
if (h && h.script) {
const p = path.join(src, h.script);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, '// artifact', 'utf8');
}
}
return src;
}
function ledgerPath(home) { return path.join(home, '.gsd-capabilities.json'); }
function capDir(home, id) { return path.join(home, '.gsd', 'capabilities', id); }
function readLedgerEntry(home, id) {
try {
const l = JSON.parse(fs.readFileSync(ledgerPath(home), 'utf8'));
return l && l.entries && l.entries[id] ? l.entries[id] : null;
} catch { return null; }
}
function parse(out) { return JSON.parse(out); }
// ─── install ────────────────────────────────────────────────────────────────
describe('capability install', () => {
test('declarative local capability installs to the global scope and records the ledger', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('declcap');
const r = runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `install failed: ${r.error || r.output}`);
const o = parse(r.output);
assert.equal(o.status, 'installed');
assert.equal(o.id, 'declcap');
assert.equal(o.scope, 'global');
assert.ok(readLedgerEntry(home, 'declcap'), 'ledger entry recorded');
assert.ok(fs.existsSync(path.join(capDir(home, 'declcap'), 'capability.json')), 'bundle extracted');
});
test('executable capability WITHOUT --yes aborts for consent and writes nothing', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('execcap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'unconsented executable install must fail');
assert.match(`${r.error}\n${r.output}`, /consent/i);
assert.equal(readLedgerEntry(home, 'execcap'), null, 'no ledger entry');
assert.ok(!fs.existsSync(capDir(home, 'execcap')), 'no install dir');
});
test('executable capability WITH --yes installs', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('execyes', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
const r = runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `install failed: ${r.error || r.output}`);
assert.equal(parse(r.output).status, 'installed');
assert.ok(readLedgerEntry(home, 'execyes'), 'ledger entry recorded');
});
test('a reserved-namespace id is blocked', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('gsd-evil');
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /blocked|reserved/i);
assert.ok(!fs.existsSync(capDir(home, 'gsd-evil')));
});
test('an engines-incompatible capability is blocked', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('engcap', { engines: { gsd: '>=99.0.0' } });
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /blocked/i);
assert.equal(readLedgerEntry(home, 'engcap'), null);
});
test('missing <spec> is a usage error', () => {
const r = runGsdTools(['capability', 'install'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Missing <spec>/i);
});
test('an invalid --scope is rejected', () => {
const src = writeCapSource('scopecap');
const r = runGsdTools(['capability', 'install', src, '--scope', 'bogus'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Invalid --scope/i);
});
});
// ─── list ─────────────────────────────────────────────────────────────────
describe('capability list', () => {
test('--json emits an array including first-party capabilities', () => {
const r = runGsdTools(['capability', 'list', '--json'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, true, `list failed: ${r.error || r.output}`);
const rows = parse(r.output);
assert.ok(Array.isArray(rows), 'list is an array');
const fp = rows.filter((x) => x.source === 'first-party');
assert.ok(fp.length > 0, 'first-party capabilities present');
assert.ok(fp.every((x) => typeof x.id === 'string' && x.scope === 'first-party'));
});
test('an installed overlay capability appears with its scope', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('listcap');
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const r = runGsdTools(['capability', 'list', '--json'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `list failed: ${r.error || r.output}`);
const row = parse(r.output).find((x) => x.id === 'listcap');
assert.ok(row, 'installed capability listed');
assert.equal(row.scope, 'global');
assert.equal(row.source, src);
assert.equal(row.version, '1.0.0');
});
});
// ─── update ─────────────────────────────────────────────────────────────────
describe('capability update', () => {
test('a not-installed id errors', () => {
const r = runGsdTools(['capability', 'update', 'nope', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /not installed/i);
});
test('requires <id> or --all', () => {
const r = runGsdTools(['capability', 'update', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /requires <id> or --all/i);
});
test('<id> and --all are mutually exclusive', () => {
const r = runGsdTools(['capability', 'update', 'foo', '--all', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /not both/i);
});
test('an installed capability upgrades to a newer version from its recorded source', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('upcap', { version: '1.0.0' });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
// Bump the recorded source to a newer version, then update by id.
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0';
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
const r = runGsdTools(['capability', 'update', 'upcap', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `update failed: ${r.error || r.output}`);
const o = parse(r.output);
assert.equal(o.status, 'upgraded');
assert.equal(o.fromVersion, '1.0.0');
assert.equal(o.toVersion, '2.0.0');
assert.equal(readLedgerEntry(home, 'upcap').version, '2.0.0');
});
// Finding 4 (MEDIUM): `capability update --shared-file` over-cap previously ran the pre-op
// reconcile (and re-parsed --shared-file per entry) BEFORE rejecting; install already had the early
// guard, update did not. The count must now be enforced BEFORE capRunReconcile.
//
// To PROVE reconcile did not run, the ledger is intentionally CORRUPT: a reconcile sweep would
// surface a "capability reconcile:" warning on stderr. The over-cap update must be rejected with a
// count error and that reconcile prefix must be ABSENT (reconcile never executed).
// Revert-fails: move the count check back below capRunReconcile (or drop it) → the corrupt-ledger
// reconcile runs first and emits "capability reconcile:" on stderr, so the "prefix absent"
// assertion fails (and/or the count error is missing).
test('finding-4: an OVER-CAP --shared-file update is rejected BEFORE the pre-op reconcile runs', () => {
const home = tmpDir('cap-cli-home-f4-');
fs.mkdirSync(home, { recursive: true });
// A corrupt ledger: if the pre-op reconcile RAN, it would emit a "capability reconcile:" warning.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const sharedArgs = [];
for (let i = 0; i < 300; i++) { sharedArgs.push('--shared-file', `f${i}.json`); } // over the 256 cap
const r = runGsdTools(
['capability', 'update', '--all', '--scope', 'global', ...sharedArgs, '--raw'],
makeCwd(), scopeEnv(home),
);
assert.equal(r.success, false, 'an over-cap --shared-file update must be rejected');
const combined = `${r.error}\n${r.output}`;
assert.match(combined, /shared.?file|count|too many|256/i,
'the failure must clearly name the shared-file count problem');
assert.doesNotMatch(combined, /capability reconcile:/i,
'the pre-op reconcile must NOT have run — the count check precedes it (finding 4)');
});
});
// ─── remove ─────────────────────────────────────────────────────────────────
describe('capability remove', () => {
test('an installed overlay capability is removed (ledger + bundle gone)', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('rmcap');
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const r = runGsdTools(['capability', 'remove', 'rmcap', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `remove failed: ${r.error || r.output}`);
assert.equal(parse(r.output).status, 'removed');
assert.equal(readLedgerEntry(home, 'rmcap'), null, 'ledger entry gone');
assert.ok(!fs.existsSync(capDir(home, 'rmcap')), 'bundle gone');
});
test('a not-installed id errors', () => {
const r = runGsdTools(['capability', 'remove', 'nope', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /not installed/i);
});
test('a first-party capability cannot be removed here', () => {
// Pick a real first-party id from the registry.
const reg = require('../gsd-core/bin/lib/capability-registry.cjs');
const firstParty = Object.keys(reg.capabilities)[0];
const r = runGsdTools(['capability', 'remove', firstParty, '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /first-party/i);
});
test('missing <id> is a usage error', () => {
const r = runGsdTools(['capability', 'remove'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Missing <id>/i);
});
});
// ─── disable / enable ─────────────────────────────────────────────────────────
describe('capability disable / enable', () => {
test('disable then enable a first-party capability toggles its activation state', () => {
const cwd = makeCwd();
const rcd = tmpDir('cap-cli-rcd-');
const off = runGsdTools(['capability', 'disable', 'ui', '--config-dir', rcd, '--raw'], cwd);
assert.equal(off.success, true, `disable failed: ${off.error || off.output}`);
const ui = parse(off.output).capabilities.find((c) => c.id === 'ui');
assert.equal(ui.enabled, false, 'ui disabled');
const on = runGsdTools(['capability', 'enable', 'ui', '--config-dir', rcd, '--raw'], cwd);
assert.equal(on.success, true, `enable failed: ${on.error || on.output}`);
assert.equal(parse(on.output).capabilities.find((c) => c.id === 'ui').enabled, true, 'ui re-enabled');
});
test('disable without <id> is a usage error', () => {
const r = runGsdTools(['capability', 'disable'], makeCwd());
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Missing <id>/i);
});
});
// ─── unknown subcommand ───────────────────────────────────────────────────────
describe('capability (unknown)', () => {
test('an unknown subcommand lists the full available set', () => {
const r = runGsdTools(['capability', 'bogus'], makeCwd());
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /install, update, remove, list, disable, enable, state, set/);
});
});
// ─── review-hardening (adversarial-review fixes) ────────────────────────────
describe('capability install (trust hardening)', () => {
test('an overlay reusing a first-party capability id is blocked', () => {
// Pick a real first-party id and try to install an overlay that shadows it.
const reg = require('../gsd-core/bin/lib/capability-registry.cjs');
const firstParty = Object.keys(reg.capabilities)[0];
const home = tmpDir('cap-cli-home-');
const src = writeCapSource(firstParty);
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /first-party capability id/i);
assert.equal(readLedgerEntry(home, firstParty), null);
});
test('a malformed strict_known_registries value fails closed (does not downgrade to permissive)', () => {
// A hand-edited string instead of an array must BLOCK an external source, not be ignored.
const cwd = makeCwdWithStrict('github.com');
const r = runGsdTools(['capability', 'install', 'https://github.com/x/y.git', '--scope', 'project'], cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /must be an array|blocked/i);
});
test('strict_known_registries: [] (lockdown) blocks an external source', () => {
const cwd = makeCwdWithStrict([]);
const r = runGsdTools(['capability', 'install', 'https://github.com/x/y.git', '--scope', 'project'], cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' });
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /external capability installs are disabled|blocked/i);
});
});
describe('capability update (id-pinning + reporting)', () => {
test('update refuses when the recorded source now resolves to a different id', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('orig');
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home)).success, true);
// Retarget the source to a different manifest id.
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.id = 'switched';
cap.version = '2.0.0';
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
const r = runGsdTools(['capability', 'update', 'orig', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /different capability id|refusing/i);
// The original is untouched; nothing named 'switched' got installed.
assert.equal(readLedgerEntry(home, 'orig').version, '1.0.0');
assert.equal(readLedgerEntry(home, 'switched'), null);
});
test('update --all exits non-zero when any entry fails to upgrade', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('exupd', { hooks: [{ event: 'PostToolUse', script: 'hooks/a.js' }] });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home)).success, true);
// Change the executable surface (new hook script) so the update needs re-consent.
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0';
cap.hooks = [{ event: 'PostToolUse', script: 'hooks/b.js' }];
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
fs.writeFileSync(path.join(src, 'hooks', 'b.js'), '// artifact');
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'partial --all failure must be non-zero');
assert.match(`${r.error}\n${r.output}`, /did not upgrade/i);
// The aborted update left the old version intact.
assert.equal(readLedgerEntry(home, 'exupd').version, '1.0.0');
});
test('a successful executable update reports the consented disclosure', () => {
const home = tmpDir('cap-cli-home-');
const src = writeCapSource('discl', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0'; // same hook script => same exec set, no re-consent needed
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
const r = runGsdTools(['capability', 'update', 'discl', '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, true, `update failed: ${r.error || r.output}`);
const o = parse(r.output);
assert.equal(o.status, 'upgraded');
assert.ok(Array.isArray(o.disclosure) && o.disclosure.length > 0, 'disclosure reported');
});
});
describe('capability disable (overlay boundary)', () => {
test('disabling an unknown id (non-raw) reports the error on stderr and exits non-zero', () => {
const rcd = tmpDir('cap-cli-rcd-');
const r = runGsdTools(['capability', 'disable', 'totally-unknown-xyz', '--config-dir', rcd], makeCwd());
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /unknown capability/i);
});
// Regression for the silent-stdout bug: a --raw command that writes a result/error envelope and
// then throws (to set a non-zero exit) used to lose ALL of stdout — captureStdoutSyncWrites
// buffered fd-1 output and discarded it on the throw path, and cmdCapabilitySet exited via
// process.exit() (bypassing the wrapper). On the old code stdout was 0 bytes; now the JSON
// error envelope is flushed to stdout AND the exit code stays non-zero.
test('disabling an unknown id in --raw mode emits the JSON error envelope on stdout (not silent)', () => {
const rcd = tmpDir('cap-cli-rcd-');
const r = runGsdTools(['capability', 'disable', 'totally-unknown-xyz', '--config-dir', rcd, '--raw'], makeCwd());
assert.equal(r.success, false, 'must exit non-zero');
assert.ok(r.output && r.output.length > 0, 'stdout must NOT be empty in raw error mode');
const out = JSON.parse(r.output);
assert.ok(Array.isArray(out.errors), 'JSON error envelope present on stdout');
assert.match(out.errors.join(' '), /unknown capability/i);
});
});
// ─── --shared-file safety + config fail-closed (adversarial-review R2) ──────
describe('capability install (--shared-file confinement)', () => {
test('a --shared-file whose parent is a symlink escaping the scope writes NOTHING outside it', () => {
const home = tmpDir('cap-cli-home-');
fs.mkdirSync(home, { recursive: true });
const outside = tmpDir('cap-cli-outside-');
// Plant a symlink inside the scope root pointing outside it.
fs.symlinkSync(outside, path.join(home, 'evil'));
const src = writeCapSource('symcap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
const r = runGsdTools(
['capability', 'install', src, '--scope', 'global', '--yes', '--shared-file', 'evil/settings.json', '--raw'],
makeCwd(), scopeEnv(home),
);
// Install still succeeds (the bundle installs); the unsafe shared-file edit is skipped.
assert.equal(r.success, true, `install failed: ${r.error || r.output}`);
assert.ok(!fs.existsSync(path.join(outside, 'settings.json')), 'must NOT write through the escaping symlink');
});
// Finding 5(b) (MEDIUM): the --shared-file COUNT must be bounded EARLY — at the CLI/lifecycle
// entry, BEFORE source resolution / staging / shared-config writes — so an over-cap install fails
// fast with a clear count error instead of writing files + leaving a _pending to reconcile.
// Revert-fails: remove the early count check in installCapability/gsd-tools → the install proceeds
// to staging (a .gsd/capabilities/.staging dir is created) before any cap is enforced, so the
// "no staging created" assertion fails (and there is no clear count error).
test('finding-5b: an install with OVER-CAP --shared-file count is rejected BEFORE any staging dir is created', () => {
const home = tmpDir('cap-cli-home-');
fs.mkdirSync(home, { recursive: true });
const src = writeCapSource('overcap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
// Build 300 --shared-file args (over the 256 generous cap).
const sharedArgs = [];
for (let i = 0; i < 300; i++) { sharedArgs.push('--shared-file', `f${i}.json`); }
const r = runGsdTools(
['capability', 'install', src, '--scope', 'global', '--yes', ...sharedArgs, '--raw'],
makeCwd(), scopeEnv(home),
);
assert.equal(r.success, false, 'an over-cap --shared-file install must be rejected');
assert.match(`${r.error}\n${r.output}`, /shared.?file|count|too many|256/i,
'the failure must clearly name the shared-file count problem');
// NO staging dir may have been created — the bound is enforced before resolution/staging.
const staging = path.join(home, '.gsd', 'capabilities', '.staging');
assert.equal(fs.existsSync(staging) && fs.readdirSync(staging).length > 0, false,
'no staging dir may be created when the over-cap install is rejected early');
// NO ledger entry / _pending must be left behind.
assert.equal(readLedgerEntry(home, 'overcap'), null, 'no ledger entry / _pending may be left');
// NO shared-config file may have been written.
assert.equal(fs.existsSync(path.join(home, 'f0.json')), false, 'no shared-config file may be written');
});
test('install does not clobber a user mcpServers entry whose name collides with the capability', () => {
const home = tmpDir('cap-cli-home-');
fs.mkdirSync(home, { recursive: true });
// Pre-existing user settings with an mcpServers entry the capability will also declare.
fs.writeFileSync(path.join(home, 'settings.json'), JSON.stringify({ mcpServers: { shared: { command: 'user-server' } } }));
const src = writeCapSource('mcpcap', { mcp: { shared: { command: 'cap-server' } } });
const r = runGsdTools(
['capability', 'install', src, '--scope', 'global', '--yes', '--shared-file', 'settings.json', '--raw'],
makeCwd(), scopeEnv(home),
);
assert.equal(r.success, true, `install failed: ${r.error || r.output}`);
const settings = JSON.parse(fs.readFileSync(path.join(home, 'settings.json'), 'utf8'));
assert.equal(settings.mcpServers.shared.command, 'user-server', 'user mcpServers entry must be preserved, not clobbered');
});
});
describe('capability install (config policy fail-closed)', () => {
test('an unparseable project config fails CLOSED — an external source is blocked, not silently permitted', () => {
const cwd = tmpDir('cap-cli-cwd-');
fs.mkdirSync(path.join(cwd, '.planning'), { recursive: true });
fs.writeFileSync(path.join(cwd, '.planning', 'config.json'), '{ this is not valid json');
const r = runGsdTools(
['capability', 'install', 'https://github.com/x/y.git', '--scope', 'project'],
cwd, { GSD_WORKSTREAM: '', GSD_PROJECT: '' },
);
assert.equal(r.success, false, 'broken config must not silently permit an external install');
assert.match(`${r.error}\n${r.output}`, /external capability installs are disabled|blocked|array/i);
});
});
// ─── code-review coverage gaps ──────────────────────────────────────────────
describe('capability (argument + empty-state handling)', () => {
test('update --all over an empty ledger succeeds with an empty result set', () => {
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, true, `update --all failed: ${r.error || r.output}`);
const o = parse(r.output);
assert.deepEqual(o.updated, [], 'no installed capabilities → empty updated list');
});
test('a flag value that looks like another flag is rejected (no value swallowing)', () => {
const src = writeCapSource('flagcap');
// `--integrity --scope` — the value after --integrity is itself a flag, which must error, not be consumed.
const r = runGsdTools(['capability', 'install', src, '--integrity', '--scope', 'global'], makeCwd(), scopeEnv(tmpDir('cap-cli-home-')));
assert.equal(r.success, false);
assert.match(`${r.error}\n${r.output}`, /Missing value for --integrity/i);
});
});
// ─── corrupt-ledger fail-closed — list + remove (sites A and C) ─────────────
describe('capability list (corrupt ledger fail-closed — site A)', () => {
test('capability list on a corrupt ledger exits non-zero with a blocked/corrupt error (finding-19)', () => {
const home = tmpDir('cap-cli-home-list-corrupt-');
fs.mkdirSync(home, { recursive: true });
// Write a corrupt (unparseable) ledger file in the global scope location.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'list', '--json', '--scope', 'global'], makeCwd(), scopeEnv(home));
// Must exit non-zero (fail-closed — finding 19). A silent exit-0 is not acceptable.
assert.equal(r.success, false, 'capability list must exit non-zero when the ledger is corrupt (fail-closed)');
const combined = `${r.error}\n${r.output}`;
assert.match(combined, /corrupt|blocked/i,
'must mention corruption or blocked, not silently fail');
});
test('capability list --scope global: healthy global + corrupt project → exits zero (finding-8)', () => {
// When --scope global is given, only the global ledger is read.
// A corrupt project ledger must not block a global-only list.
// Project scope runtimeDir = cwd (where .gsd-capabilities.json would live).
const home = tmpDir('cap-cli-home-list-scoped-');
fs.mkdirSync(home, { recursive: true });
const cwd = makeCwd();
// Write a corrupt ledger at the project scope location (cwd/.gsd-capabilities.json).
fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---');
const r = runGsdTools(['capability', 'list', '--json', '--scope', 'global'], cwd, scopeEnv(home));
// Global scope is healthy (no ledger = null = fine). Only the global scope is read.
assert.equal(r.success, true, `list --scope global must succeed when only the project ledger is corrupt; got: ${r.error || r.output}`);
const rows = parse(r.output);
assert.ok(Array.isArray(rows), 'output must be a JSON array');
// First-party capabilities must appear (they are always included).
assert.ok(rows.some((x) => x.source === 'first-party'), 'first-party entries must appear');
});
test('capability list --scope project: corrupt project ledger exits non-zero (finding-8)', () => {
const home = tmpDir('cap-cli-home-list-proj-corrupt-');
fs.mkdirSync(home, { recursive: true });
const cwd = makeCwd();
// Project scope runtimeDir = cwd, so corrupt ledger goes at cwd/.gsd-capabilities.json.
fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---');
const r = runGsdTools(['capability', 'list', '--json', '--scope', 'project'], cwd, scopeEnv(home));
assert.equal(r.success, false, 'list --scope project must fail when the project ledger is corrupt');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, 'must mention corruption');
});
});
describe('capability remove (corrupt ledger fail-closed — site C)', () => {
test('capability remove on a corrupt global ledger exits non-zero with a blocked/corrupt error, NOT not_installed or silent success', () => {
const home = tmpDir('cap-cli-home-remove-corrupt-');
fs.mkdirSync(home, { recursive: true });
// Write a corrupt (unparseable) ledger file so the scope has one.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'remove', 'some-cap', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt ledger');
// Must NOT silently report "not installed" — that would hide the corruption.
assert.doesNotMatch(`${r.error}\n${r.output}`, /not installed/i,
'corrupt ledger must NOT produce "not installed" — must produce a blocked/corrupt error');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i,
'must mention corruption or blocked');
});
test('capability remove first-party id on corrupt ledger surfaces corruption, not first-party error (finding-7)', () => {
// Finding 7: with readLedger (old), a corrupt ledger + first-party id reports "first-party cannot be removed"
// (hiding the corruption). With readLedgerStrict, corruption is surfaced first.
const reg = require('../gsd-core/bin/lib/capability-registry.cjs');
const firstParty = Object.keys(reg.capabilities)[0];
const home = tmpDir('cap-cli-home-f7-');
fs.mkdirSync(home, { recursive: true });
// Corrupt the ledger.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'remove', firstParty, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt ledger');
// Must NOT report "first-party" (which would hide the corruption).
assert.doesNotMatch(`${r.error}\n${r.output}`, /first-party/i,
'corrupt ledger must surface corruption, not first-party gate');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i,
'must mention corruption or blocked');
});
test('capability remove on a corrupt project-scope ledger exits non-zero (finding-20)', () => {
const home = tmpDir('cap-cli-home-remove-proj-corrupt-');
fs.mkdirSync(home, { recursive: true });
const cwd = makeCwd();
// Project scope runtimeDir = cwd, so corrupt ledger goes at cwd/.gsd-capabilities.json.
fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project json ---');
const r = runGsdTools(['capability', 'remove', 'some-cap', '--scope', 'project'], cwd, scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt project ledger');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i, 'must mention corruption or blocked');
});
});
// ─── corrupt-ledger fail-closed (Codex pass 3 — medium #2) ──────────────────
describe('capability update (corrupt ledger fail-closed)', () => {
test('capability update <id> on a corrupt ledger exits non-zero with a blocked/corrupt error, NOT not_installed', () => {
const home = tmpDir('cap-cli-home-corrupt-');
fs.mkdirSync(home, { recursive: true });
// Write a corrupt (unparseable) ledger file.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'update', 'some-cap', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt ledger');
// Must NOT report "not installed" — that would hide the corruption silently.
assert.doesNotMatch(`${r.error}\n${r.output}`, /not installed/i,
'corrupt ledger must NOT produce "not installed" — must produce a blocked/corrupt error');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i,
'must mention corruption or blocked');
});
test('capability update --all on a corrupt ledger exits non-zero, does NOT silently succeed with an empty list', () => {
const home = tmpDir('cap-cli-home-corrupt-all-');
fs.mkdirSync(home, { recursive: true });
// Write a corrupt (unparseable) ledger file.
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'must exit non-zero on corrupt ledger for --all');
assert.match(`${r.error}\n${r.output}`, /corrupt|blocked/i,
'must mention corruption or blocked; not silently succeed');
});
});
// ─── orthogonal adversarial review (#1462): UX / observability ──────────────
describe('capability update --all (UX-1: structured stdout on partial failure)', () => {
test('UX-1: a partial --all failure emits {scope, updated:[...]} JSON on STDOUT and exits non-zero', () => {
const home = tmpDir('cap-cli-home-ux1-');
// Install an executable capability, then change its exec surface so the update needs re-consent
// and (without --yes) ABORTS — a partial-failure --all run.
const src = writeCapSource('ux1cap', { hooks: [{ event: 'PostToolUse', script: 'hooks/a.js' }] });
assert.equal(runGsdTools(['capability', 'install', src, '--scope', 'global', '--yes', '--raw'], makeCwd(), scopeEnv(home)).success, true);
const cap = JSON.parse(fs.readFileSync(path.join(src, 'capability.json'), 'utf8'));
cap.version = '2.0.0';
cap.hooks = [{ event: 'PostToolUse', script: 'hooks/b.js' }];
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
fs.writeFileSync(path.join(src, 'hooks', 'b.js'), '// artifact');
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
// Non-zero exit (partial failure).
assert.equal(r.success, false, 'a partial --all failure must exit non-zero');
// STRUCTURED data on STDOUT (not embedded in the error string) — UX-1.
assert.ok(r.output && r.output.length > 0, 'structured result must be emitted on stdout');
const parsed = JSON.parse(r.output);
assert.equal(parsed.scope, 'global', 'stdout JSON must carry the scope');
assert.ok(Array.isArray(parsed.updated), 'stdout JSON must carry the updated[] array');
assert.ok(parsed.updated.some((x) => x.id === 'ux1cap' && x.status !== 'upgraded'),
`updated[] must include the failed entry; got: ${JSON.stringify(parsed.updated)}`);
});
});
describe('capability list (UX-3: corrupt-scope error names the scope)', () => {
test('UX-3: a corrupt project-scope ledger error names the scope', () => {
const home = tmpDir('cap-cli-home-ux3-');
fs.mkdirSync(home, { recursive: true });
const cwd = makeCwd();
fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), '{ broken project ledger ---');
const r = runGsdTools(['capability', 'list', '--json', '--scope', 'project'], cwd, scopeEnv(home));
assert.equal(r.success, false, 'list --scope project must fail when the project ledger is corrupt');
assert.match(`${r.error}\n${r.output}`, /\bproject\b/,
`the corrupt-scope error must name the scope ("project"); got: ${r.error}\n${r.output}`);
});
});
describe('capability install (UX-5: structured aborted/requiresConsent on stdout)', () => {
test('UX-5: an executable install WITHOUT --yes in --raw mode emits a structured aborted envelope on stdout', () => {
const home = tmpDir('cap-cli-home-ux5-');
const src = writeCapSource('ux5cap', { hooks: [{ event: 'PostToolUse', script: 'hooks/run.js' }] });
const r = runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'an executable install without --yes must exit non-zero');
assert.ok(r.output && r.output.length > 0, 'stdout must NOT be empty in raw aborted mode (UX-5)');
const out = JSON.parse(r.output);
assert.equal(out.status, 'aborted', 'structured stdout must carry status=aborted');
assert.equal(out.requiresConsent, true, 'structured stdout must carry requiresConsent=true');
assert.ok(Array.isArray(out.disclosure), 'structured stdout must carry the disclosure list');
});
});
describe('capability update (UX-6: normalized per-entry fields)', () => {
test('UX-6: a not_installed entry in --all output has explicit null fields (not undefined)', () => {
// Seed a ledger with an entry whose recorded source resolves to a DIFFERENT id, so upgradeOne
// reports a non-upgraded status with no fromVersion/toVersion — those must serialize as null.
const home = tmpDir('cap-cli-home-ux6-');
fs.mkdirSync(home, { recursive: true });
// Hand-write a ledger entry pointing at a non-existent source so the update blocks.
const ledger = {
version: '1', updatedAt: new Date().toISOString(),
entries: {
'ux6cap': { id: 'ux6cap', version: '1.0.0', source: '/nonexistent/path/that/does/not/resolve', integrity: '', files: [], sharedEdits: [] },
},
};
fs.writeFileSync(ledgerPath(home), JSON.stringify(ledger, null, 2));
const r = runGsdTools(['capability', 'update', '--all', '--scope', 'global', '--raw'], makeCwd(), scopeEnv(home));
// Partial failure (the blocked entry) → non-zero, structured stdout.
assert.equal(r.success, false, 'a blocked --all entry must exit non-zero');
const parsed = JSON.parse(r.output);
const row = parsed.updated.find((x) => x.id === 'ux6cap');
assert.ok(row, `updated[] must include ux6cap; got: ${JSON.stringify(parsed.updated)}`);
// JSON.stringify omits undefined keys; explicit null is preserved. The fields must be present
// as null (normalized), not absent.
assert.ok('fromVersion' in row, 'fromVersion must be an explicit field (null), not omitted (UX-6)');
assert.strictEqual(row.fromVersion, null, 'fromVersion must be null for a blocked entry (UX-6)');
assert.ok('toVersion' in row, 'toVersion must be an explicit field (null), not omitted (UX-6)');
assert.strictEqual(row.toVersion, null, 'toVersion must be null for a blocked entry (UX-6)');
});
});
describe('capability install (UX-2: reconcile warnings surfaced on stderr)', () => {
// Revert-fails: restore the bare `try{reconcile}catch{}` that discards the report → the distinctive
// "capability reconcile:" warning prefix is never emitted to stderr, so this assertion fails. (The
// install block reason references "corrupt" but NOT the reconcile-warning prefix, so the prefix
// assertion is non-vacuous.)
test('UX-2: a corrupt ledger detected by the pre-op reconcile is surfaced on stderr (not swallowed)', () => {
const home = tmpDir('cap-cli-home-ux2-');
fs.mkdirSync(home, { recursive: true });
fs.writeFileSync(ledgerPath(home), '{ broken json ---');
const src = writeCapSource('ux2cap');
const r = runGsdTools(['capability', 'install', src, '--scope', 'global'], makeCwd(), scopeEnv(home));
assert.equal(r.success, false, 'install on a corrupt ledger must exit non-zero');
// The reconcile report's warning must be surfaced with its distinctive prefix on stderr — proving
// the report was captured and emitted, not discarded in a bare try/catch.
assert.match(`${r.error}\n${r.output}`, /capability reconcile:/i,
'the pre-op reconcile warning must be surfaced on stderr with its prefix (UX-2)');
});
});