fix(#2322): materialize installed third-party capability skills (#2340)

* 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:
Tom Boucher
2026-07-17 06:50:06 -04:00
committed by GitHub
parent 19c1f54a2a
commit 23a65c4a3d
12 changed files with 1026 additions and 25 deletions

View File

@@ -55,6 +55,20 @@ interface ResolvedProfile {
agents: Set<string>;
}
/**
* #2322: mirrors the (unexported) CapabilityRegistry shape in install-profiles.cts.
* Threaded through resolveRuntimeArtifactLayout -> skillsKind so the skills-kind
* stage() closure can bind a third-party capability skill stem to its DECLARING
* capability (capabilityClusters) at staging time — never by scanning the
* installed capabilities root and guessing. Optional: a caller with no registry
* in scope gets a layout whose skills kind stages NOTHING third-party (fail
* closed), matching install-profiles.cts's own registry-optional contract.
*/
interface CapabilityRegistryForSkills {
capabilityClusters?: Record<string, string[]>;
profileMembership?: Record<string, { tier: string; profiles: string[] }>;
}
/**
* Cross-cutting context for descriptor-driven agent staging (ADR-1235 §1).
* Passed as the optional second arg to ArtifactKind.stage() for agents kind
@@ -316,6 +330,10 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string):
* arg so scope-aware converters (antigravity, copilot) can choose
* between global home paths and workspace-relative paths without
* colliding with the `runtime` string at position 3.
* @param capabilityRegistry #2322: optional capability registry — captured in the
* stage() closure so third-party capability skills are bound to
* their declaring capId at staging time. Absent -> stage() stages
* nothing third-party (fail closed).
*/
function skillsKind(
destSubpath: string,
@@ -325,6 +343,7 @@ function skillsKind(
configDir: string,
nested = false,
scope: 'local' | 'global' = 'global',
capabilityRegistry?: CapabilityRegistryForSkills,
): ArtifactKind {
return {
kind: 'skills',
@@ -345,7 +364,7 @@ function skillsKind(
const isGlobal = scope === 'global';
const wrappedConverter = (content: string, skillName: string): string =>
realConverter(content, skillName, runtime, cmdNames, isGlobal);
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested);
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested, capabilityRegistry);
},
};
}
@@ -465,7 +484,7 @@ function getRegistry(): RegistryLike {
* Map a single ArtifactKindDescriptor entry to an ArtifactKind using the
* matching builder function. Mirrors the hand-built calls in the old switch.
*/
function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, configDir: string, scope: 'local' | 'global'): ArtifactKind {
function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, configDir: string, scope: 'local' | 'global', capabilityRegistry?: CapabilityRegistryForSkills): ArtifactKind {
const { kind, destSubpath, prefix, nesting, converter } = entry;
const nested = nesting === 'nested';
@@ -489,7 +508,7 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`,
);
}
result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
result = skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope, capabilityRegistry);
break;
case 'kimi-agents':
@@ -514,9 +533,19 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
*
* ADR-857 phase 5d: driven by the capability-registry artifactLayout descriptor
* instead of a hardcoded switch statement.
*
* @param capabilityRegistry #2322: optional — when the caller has a composed
* capability registry in scope (e.g. capability-writer.cts's `capability set`
* path, or a fresh install's registry-aware profile resolution), pass it here
* so the skills kind's stage() closure can materialize installed third-party
* capability skills bound to their declaring capId. Both call paths (surface
* apply AND the installer) must pass their registry here — resolveProfile's
* own `'*'` (full profile) short-circuit never carries a registry, so if it
* is not threaded in at layout-build time a `full`-profile install stages no
* third-party capability skills regardless of registration (#2322 blocker 2).
*/
function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: 'local' | 'global' = 'global'): Layout {
return resolveRuntimeArtifactLayoutFromRegistry(getRegistry(), runtime, configDir, scope);
function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: 'local' | 'global' = 'global', capabilityRegistry?: CapabilityRegistryForSkills): Layout {
return resolveRuntimeArtifactLayoutFromRegistry(getRegistry(), runtime, configDir, scope, capabilityRegistry);
}
function resolveRuntimeArtifactLayoutFromRegistry(
@@ -524,6 +553,7 @@ function resolveRuntimeArtifactLayoutFromRegistry(
runtime: string,
configDir: string,
scope: 'local' | 'global' = 'global',
capabilityRegistry?: CapabilityRegistryForSkills,
): Layout {
if (typeof configDir !== 'string' || configDir === '') {
throw new TypeError('configDir must be a non-empty string');
@@ -538,7 +568,7 @@ function resolveRuntimeArtifactLayoutFromRegistry(
}
const entries: ArtifactKindDescriptor[] = desc[scope] ?? [];
const kinds: ArtifactKind[] = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope));
const kinds: ArtifactKind[] = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope, capabilityRegistry));
return { runtime, configDir, scope, kinds };
}