* test(#2322): fail-first tests for third-party capability skill materialization Red phase: tests (1) and (6) fail — resolveSurface reports the third-party stem surfaced (#2045) but no SKILL.md is ever written to disk. The other four are controls that must keep holding: first-party-wins collision, profile-tier filter, nested-router layout unperturbed, and absent/malformed capability must not throw. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA * fix(#2322): materialize installed third-party capability skills A capability could report installed:true, surfaced:true, active:true and still never exist as an invocable command. #2045 fixed the registry layer — resolveSurface unions registry.capabilityClusters into the resolved skill set — but the materialization layer never got the matching fix. stageSkillsForRuntimeAsSkills only ever read gsd-core's own bundled commands/gsd/*.md and silently skipped any stem it couldn't find there, so a third-party skill living at <GSD_HOME>/.gsd/capabilities/<id>/skills/<stem>/ was never copied. Registry said surfaced; disk had nothing. Installed capability skills are now staged alongside the first-party ones, copied verbatim (they are authored complete for their target runtime and need no converter). First-party stems always win a collision, the profile filter still applies, and an absent or malformed capability degrades rather than throwing. Security: capability.json's skills[] entries are validated only as non-empty non-reserved strings (capability-validator.cjs:503-514) — no path shape is enforced upstream — so stems are sanitized (rejecting separators, '..', absolute paths, NUL) with an independent isPathConfined check on both the read and write paths. A '../../evil' stem writes nothing outside the capability's own dir. Also fixes a defect this surfaced in pruneSkillDirs: a materialized capability skill dir has no first-party manifest entry, so every apply logged "preserving (user-owned or unknown)" for a live GSD-managed dir. The retained check now precedes the manifest gate; no deletion outcome changes, and genuinely unknown gsd-* dirs still warn and are preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA * fix(#2322): address security review — bind skills to declaring capability, fix full profile An independent security review BLOCKED the first pass. Both blockers were mine. BLOCKER 1 (security): readInstalledCapabilitySkill scanned every capability dir and returned the first sorted match, never checking that a capability DECLARES the stem — ownership was inferred from attacker-controlled filesystem layout. Since install copies the whole bundle and the validator only checks DECLARED entries, a capability declaring `skills: []` could ship an undeclared skills/deploy/SKILL.md and win the `deploy` stem on sort order, supplying the agent-invocable instructions the user believed came from the registered capability. Stems are now bound to their owning capId via registry.capabilityClusters, and only that capability's dir is read. BLOCKER 2: the fill-in pass was gated `skills !== '*'` on the premise that applySurface materializes `full` into a concrete Set. True for applySurface — false for the installer, which is the default path: resolveProfile returns the '*' sentinel and bin/install.js passes it straight to staging. So #2322 survived on the default `full` profile, i.e. the fix didn't fix the reported bug. The registry is now plumbed to staging, and '*' stages all capability-cluster stems. Wiring this surfaced a second gap: the ADR-1239 imperative adapter (the primary install path) never threaded its registry either, which would have silently defeated the fix on the real default install. HIGH: staged capability skills were never prunable — pruneSkillDirs gates on the first-party manifest, so uninstalling a capability left its instructions live in the agent's context forever. Staged skills now carry a marker making them GSD-owned and prunable; genuinely unknown gsd-* dirs still warn and are preserved. MEDIUM: the "staged verbatim" claim was false — applySurface rewrites bodies over the whole stage dir. The tests asserted byte-equality and passed only because their fixtures contained no rewrite triggers. Claim dropped; tests now assert the rewrite against triggering content. LOW: isPathConfined is lexical, not realpath (symlink-defeatable, currently unreachable because install rejects symlinks) — comment corrected. The validator does not enforce non-empty, so isSafeCapabilitySkillStem is the sole defense, not a second layer — comment corrected and it now has traversal/NUL/absolute/empty test coverage (previously mutating it to `return true` left every test green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA * test(#2322): pin that the imperative adapter forwards a capability registry The delegation-args test deep-equalled the exact argv to installRuntimeArtifacts, so threading the composed capability registry through the ADR-1239 imperative adapter (required for #2322 — without it the default `full` install path never materializes third-party capability skills) failed it. The contract legitimately gained a parameter, so this is a stale-test correction, not a regression. Rather than deep-equalling the whole composed registry (brittle — it embeds the full agent/profile map), the test pins the leading args exactly and asserts only that a registry-shaped value is forwarded. That still fails if the adapter stops threading it, which is the regression the test exists to catch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA * docs(#2322): backfill PR number 2340 into changeset Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SLufH5sDuqA1AiEGu45cuA --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1084,3 +1084,89 @@ describe('capability consent store (#1459)', () => {
|
||||
assert.match(`${r.error}\n${r.output}`, /trust/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── issue #2322: capability set --runtime materializes installed skills ──────
|
||||
|
||||
describe('issue-2322: capability set --runtime materializes an installed third-party capability skill end-to-end', () => {
|
||||
/**
|
||||
* A conformant local capability source that declares one owned skill
|
||||
* (`skills: [stem]`) and ships that skill's already-authored SKILL.md
|
||||
* verbatim under `skills/<stem>/SKILL.md` — mirroring the real on-disk
|
||||
* shape `capability install` copies into `~/.gsd/capabilities/<id>/`.
|
||||
*/
|
||||
function writeCapSourceWithSkill(id, stem, skillContent, { tier = 'standard' } = {}) {
|
||||
const src = tmpDir(`cap-cli-src-${id}-`);
|
||||
const cap = {
|
||||
id,
|
||||
role: 'feature',
|
||||
version: '1.0.0',
|
||||
title: id,
|
||||
description: 'test capability with a skill',
|
||||
tier,
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [stem],
|
||||
agents: [],
|
||||
hooks: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
fs.writeFileSync(path.join(src, 'capability.json'), JSON.stringify(cap, null, 2));
|
||||
const skillDir = path.join(src, 'skills', stem);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), skillContent, 'utf8');
|
||||
return src;
|
||||
}
|
||||
|
||||
test('reproduces #2322: capability state reports surfaced:true but the skill is not written to <config-dir>/skills/', () => {
|
||||
const home = tmpDir('cap-cli-home-');
|
||||
const cwd = makeCwd();
|
||||
// #2322 MEDIUM-4: a fixture with NO rewrite-triggering content would pass
|
||||
// the byte-for-byte assertion below vacuously (it never proves the
|
||||
// runtime body rewrite pass — which DOES run over third-party skills too —
|
||||
// actually ran). Embed a `~/.claude/` reference so the later assertion
|
||||
// exercises that pass instead of asserting its absence.
|
||||
const AUTHORED = '---\nname: my-thing\ndescription: third-party test skill\n---\n\n# My Thing\nSee @~/.claude/workflows/example.md for details.\n';
|
||||
const src = writeCapSourceWithSkill('my-thing', 'my-thing', AUTHORED);
|
||||
|
||||
// Step 2 of the issue repro: capability install ./capabilities/my-thing --yes
|
||||
const install = runGsdTools(['capability', 'install', src, '--scope', 'global', '--raw'], cwd, scopeEnv(home));
|
||||
assert.equal(install.success, true, `install failed: ${install.error || install.output}`);
|
||||
assert.equal(parse(install.output).status, 'installed');
|
||||
assert.ok(fs.existsSync(path.join(capDir(home, 'my-thing'), 'skills', 'my-thing', 'SKILL.md')), 'sanity: install copies the skill verbatim into the capabilities root');
|
||||
|
||||
// Step 3 of the issue repro: capability set my-thing --runtime claude --scope global
|
||||
const rcd = tmpDir('cap-cli-rcd-');
|
||||
const setResult = runGsdTools(
|
||||
['capability', 'set', 'my-thing', '--runtime', 'claude', '--scope', 'global', '--config-dir', rcd, '--raw'],
|
||||
cwd,
|
||||
scopeEnv(home),
|
||||
);
|
||||
assert.equal(setResult.success, true, `capability set failed: ${setResult.error || setResult.output}`);
|
||||
const setOut = parse(setResult.output);
|
||||
assert.deepEqual(setOut.errors, [], `capability set reported errors: ${JSON.stringify(setOut.errors)}`);
|
||||
|
||||
// Step 4 of the issue repro: capability state --raw -> installed:true, surfaced:true
|
||||
const myThing = setOut.capabilities.find((c) => c.id === 'my-thing');
|
||||
assert.ok(myThing, 'my-thing present in the returned capability state');
|
||||
assert.equal(myThing.installed, true, 'installed:true (matches issue step 4)');
|
||||
assert.equal(myThing.surfaced, true, 'surfaced:true (matches issue step 4 — #2045 registry union already works)');
|
||||
|
||||
// Step 5 of the issue repro: ls ~/.claude/skills/gsd-my-thing/ -> No such file or directory (THE BUG)
|
||||
const stagedPath = path.join(rcd, 'skills', 'gsd-my-thing', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(stagedPath),
|
||||
`gsd-my-thing/SKILL.md must exist under ${rcd}/skills/ — capability state reports surfaced:true but nothing was materialized to disk (#2322)`,
|
||||
);
|
||||
const staged = fs.readFileSync(stagedPath, 'utf8');
|
||||
assert.notEqual(
|
||||
staged,
|
||||
AUTHORED,
|
||||
'#2322 MEDIUM-4: a third-party skill is NOT immune from the runtime-targeted body rewrite pass that runs over the whole stage dir (rewriteStagedSkillBodies) — a byte-for-byte-equal assertion here would be vacuous',
|
||||
);
|
||||
assert.ok(!staged.includes('~/.claude/'), 'the ~/.claude/ literal must have been rewritten to the resolved path prefix');
|
||||
assert.ok(staged.includes('workflows/example.md'), 'the rewrite must preserve the referenced path suffix, not corrupt the body');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user