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

@@ -62,12 +62,20 @@ export function createImperativeAdapter(
runtime,
registry,
install(intent: AdapterInstallIntent): void {
// #2322: thread the SAME composed registry (loaded above, includeInstalled:true)
// this adapter exposes via `.registry` into the engine call, so the skills
// kind's stage() closure can bind an installed third-party capability
// skill to its declaring capId at staging time — this is the PRIMARY
// install path (bin/install.js prefers the adapter over the direct
// installRuntimeArtifacts fallback), so without this the adapter path
// never staged a third-party capability skill regardless of registration.
installEngine.installRuntimeArtifacts(
runtime,
intent.configDir,
intent.scope,
intent.resolvedProfile,
intent.resolveAttribution,
registry,
);
},
uninstall(intent: AdapterUninstallIntent): void {

View File

@@ -355,10 +355,15 @@ function setCapabilityState(
// eslint-disable-next-line @typescript-eslint/no-require-imports
const runtimeArtifactLayout = require('./runtime-artifact-layout.cjs') as {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolveRuntimeArtifactLayout: (runtime: string, configDir: string, scope: string) => any;
resolveRuntimeArtifactLayout: (runtime: string, configDir: string, scope: string, capabilityRegistry?: unknown) => any;
};
// #2322: thread the SAME composed registry (loaded above, includeInstalled:true)
// into layout resolution so the skills kind's stage() closure can bind a
// third-party capability skill to its declaring capId at staging time —
// required for BOTH the '*' (full-profile) fill-in and the ownership binding
// (see resolveRuntimeArtifactLayout's #2322 doc comment).
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope);
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, resolvedConfigDir, scope, registry);
const commandsGsdDir = _resolveCommandsGsdDir();
const manifest = _resolveManifest(commandsGsdDir, resolvedConfigDir);
// #1575: applySurface now accepts opts.resolveAttribution so surface-path

View File

@@ -22,9 +22,21 @@
import path from 'node:path';
/**
* Pure path-containment check (cross-platform). `target` is confined to `root`
* iff resolving it relative to `root` yields a path equal to or under `root`.
* Pure LEXICAL path-containment check (cross-platform). `target` is confined
* to `root` iff resolving it relative to `root` (via `path.resolve` — string
* manipulation, no filesystem access) yields a path equal to or under `root`.
* Absolute paths outside `root` and `..`-escapes return false.
*
* NOT a realpath check: this function never calls `fs.realpathSync` and does
* not detect a symlink along `target` (or an existing path component of
* `root`) that would redirect the LEXICALLY-confined path to a physically
* different, unconfined location on disk. A caller relying on this for a
* write-confinement guarantee against a symlink-planting attacker must pair
* it with a symlink check (or refuse to follow symlinks at write time) — see
* capability-source.cts's install adapters (:491,577,675), which is what
* currently keeps every caller of this function's callers symlink-safe: they
* reject symlinks upstream, before a target ever reaches a lexical-only check
* like this one.
*/
export function isPathConfined(target: string, root: string): boolean {
if (typeof target !== 'string' || typeof root !== 'string' || target.length === 0 || root.length === 0) {

View File

@@ -600,6 +600,11 @@ function _runLegacyUninstallCleanup(runtime: string, configDir: string, scope: s
* @param scope
* @param resolvedProfile from resolveProfile() / resolveEffectiveProfile()
* @param resolveAttribution injection: (runtime) => attribution string | undefined
* @param capabilityRegistry #2322: optional composed capability registry
* (capabilityClusters view) — threaded into resolveRuntimeArtifactLayout so
* the skills kind can materialize installed third-party capability skills
* bound to their declaring capId. Absent -> no third-party skills staged
* (fail closed), matching the layout resolver's own optional-registry contract.
*/
function installRuntimeArtifacts(
runtime: string,
@@ -607,6 +612,7 @@ function installRuntimeArtifacts(
scope: string,
resolvedProfile: any,
resolveAttribution: ResolveAttribution = () => undefined,
capabilityRegistry?: any,
): void {
// Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
// the dedicated combined commands+skills+plugin orchestrator instead of the
@@ -621,7 +627,7 @@ function installRuntimeArtifacts(
// Legacy cleanup before layout-driven writes
_runLegacyInstallMigrations(runtime, configDir, scope);
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as 'global' | 'local');
const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope as 'global' | 'local', capabilityRegistry);
const planResult = runtimeArtifactInstallPlan.createRuntimeArtifactInstallPlan({
// `Layout` is structurally identical across the layout/install-plan .cjs
// modules but nominally distinct to tsc (untyped .cjs boundary) — bridge it.

View File

@@ -11,6 +11,9 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { platformWriteSync } from './shell-command-projection.cjs';
// #2322: reuse the existing pure path-containment seam (ADR-1239 Phase C-2)
// instead of hand-rolling a new traversal check for capability skill stems.
import { isPathConfined } from './external-descriptor-trust.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import conversionModule = require('./runtime-artifact-conversion.cjs');
const {
@@ -470,12 +473,174 @@ function transformRouterBodyToNested(converted: string): string {
return out.join('\n');
}
/**
* #2322 SECURITY: a third-party `capability.json`'s `skills[]` entries are only
* validated for being STRINGS and not one of the 3 reserved prototype-pollution
* names (capability-validator.cjs validateFeatureBody, ~line 503) — NOT for
* non-emptiness and NOT for a safe path-segment shape. `isSafeCapabilitySkillStem`
* is therefore the SOLE defense against an empty-string, `..`-escaping,
* separator-carrying, absolute, or NUL-carrying stem reaching a filesystem path
* as a literal component — not a second defense-in-depth layer on top of any
* validator-enforced non-emptiness (there is none). Once unioned into
* resolveSurface's `resolved.skills` (#2045), such a stem must never reach
* fs.readFileSync/writeFileSync as a literal path component, or it can escape
* the capabilities root on read (or stageDir on write). Reject anything but a
* single, ordinary path segment.
*/
function isSafeCapabilitySkillStem(stem: string): boolean {
if (typeof stem !== 'string' || stem.length === 0) return false;
if (stem.includes('\0')) return false;
if (stem === '.' || stem === '..') return false;
if (stem.includes('/') || stem.includes('\\')) return false;
if (path.isAbsolute(stem)) return false;
return true;
}
/**
* Resolve which capability id DECLARES ownership of `stem`, per the registry's
* `capabilityClusters` view (capId -> [owned skill stems]) — the SAME
* authoritative binding `_capabilitySkillsForMode` (above) and `resolveSurface`
* (surface.cts) already trust to decide which stems a capability contributes.
* `capabilityClusters` is derived (gen-capability-registry.cjs
* deriveCapabilityClusters) straight from each ACCEPTED capability's OWN
* declared, non-empty `skills[]` array — an UNDECLARED directory a capability
* happens to ship on disk (an unlisted `skills/<stem>/` bundled by mistake, or
* by a malicious author trying to hijack another capability's stem) never
* appears here, so it can never resolve as an owner. Two capabilities can never
* both own the same stem: the registry loader (capability-loader.cts) rejects a
* candidate whose declared skill collides with an already-registered owner
* BEFORE it is ever composed into the registry — so this lookup is unambiguous
* by construction. Returns null for an unowned/unregistered stem or a
* malformed registry (never throws).
*/
function _owningCapabilityId(stem: string, clusters: Record<string, string[]>): string | null {
const BANNED = ['__proto__', 'constructor', 'prototype'];
for (const capId of Object.keys(clusters)) {
if (BANNED.includes(capId)) continue;
const owned = clusters[capId];
if (!Array.isArray(owned)) continue;
if (owned.includes(stem)) return capId;
}
return null;
}
/**
* Union every stem ANY accepted capability declares across the WHOLE registry
* (unfiltered by mode/tier) — used only for the `'*'` (full profile) staging
* fill-in below, mirroring the SAME unconditional union `resolveSurface`
* (surface.cts) already performs when ITS OWN base profile resolves to `'*'`.
* Guards against a malformed/prototype-polluted registry; never throws.
*/
function capabilityClusterStems(registry: CapabilityRegistry | undefined): Set<string> {
const result = new Set<string>();
const clusters = registry?.capabilityClusters;
if (!clusters || typeof clusters !== 'object') return result;
const BANNED = ['__proto__', 'constructor', 'prototype'];
for (const capId of Object.keys(clusters)) {
if (BANNED.includes(capId)) continue;
const stems = clusters[capId];
if (!Array.isArray(stems)) continue;
for (const s of stems) {
if (typeof s === 'string' && s.length > 0) result.add(s);
}
}
return result;
}
/**
* #2322 HIGH-3: filesystem marker written into every staged THIRD-PARTY
* capability skill directory (alongside SKILL.md) so a later prune pass
* (surface.cts pruneSkillDirs) can identify the directory as GSD-capability-
* owned even after the owning capability has been uninstalled/unsurfaced and
* no longer appears in ANY registry view. Without a persisted marker, an
* orphaned capability skill directory has no first-party manifest entry (the
* skill manifest only ever knows gsd-core's own bundled stems) and
* pruneSkillDirs' conservative unknown-directory branch would preserve it
* FOREVER — uninstalling a malicious capability would never actually remove
* its already-staged instructions from the agent's context. A directory
* WITHOUT this marker is presumed genuinely user-created (data-loss
* protection is unchanged for that case).
*/
const CAPABILITY_SKILL_MARKER = '.gsd-capability-skill';
/**
* Look up an installed third-party capability's already-authored SKILL.md for
* `stem`, bound to its DECLARING capability via the registry's
* `capabilityClusters` view (capId -> owned stems) — NEVER by scanning every
* installed capability directory and taking the first (sorted) match.
*
* #2322 BLOCKER 1: the prior implementation scanned every directory under the
* capabilities root for a `skills/<stem>/SKILL.md` file and returned the FIRST
* SORTED match, regardless of whether that capability actually DECLARED the
* stem in its `capability.json` `skills[]` and regardless of whether it was
* the (sole) REGISTERED owner. An attacker-controlled capability could ship an
* UNDECLARED `skills/<victim-stem>/SKILL.md` directory that sorted ahead of
* the legitimate, declaring capability and hijack its stem — the agent would
* load the attacker's instructions believing they came from the legitimate
* capability. Resolving `stem -> capId` via `capabilityClusters` FIRST (the
* same authoritative binding `resolveSurface`/`_capabilitySkillsForMode`
* trust) then reading ONLY that capability's own directory makes an
* undeclared/unregistered sibling directory unreachable by construction.
*
* The install-root path convention (`<capabilitiesRoot>/<capId>/skills/<stem>/
* SKILL.md` under `GSD_HOME || homedir()`) mirrors capability-loader.cts
* (global overlay root) and capability-source.cts's `stageValidated` finalDir.
*
* Total/non-throwing (#2322 requirement 5): no registry, an unowned stem, a
* missing capabilities root, an unreadable capability dir, or a missing/
* corrupt SKILL.md all degrade to `null` (skip that stem) rather than
* throwing — a partial/corrupt third-party install must never break
* first-party staging. No registry at all means NOTHING third-party is
* staged (fail closed — never a fallback scan).
*
* NOTE: the content returned here is staged AS-IS (no per-file `converter`
* runs on it — unlike gsd-core's flat command `.md`, an installed capability
* skill is already a complete SKILL.md), but it is NOT immune from the LATER
* runtime-targeted body rewrite pass `applySurface` runs over the ENTIRE
* staged directory (`rewriteStagedSkillBodies`, surface.cts): a `~/.claude/`
* (etc.) path reference in a third-party skill body IS rewritten exactly like
* a first-party one. "As-is" here refers only to this copy step, not to the
* final on-disk content after a full `applySurface` run.
*/
function readInstalledCapabilitySkill(stem: string, registry: CapabilityRegistry | undefined): { capId: string; content: string } | null {
if (!isSafeCapabilitySkillStem(stem)) return null;
if (!registry || !registry.capabilityClusters || typeof registry.capabilityClusters !== 'object') return null;
const capId = _owningCapabilityId(stem, registry.capabilityClusters);
if (capId === null) return null;
// Defense-in-depth: capId is a real accepted-capability directory name (a
// trusted fs.readdirSync entry at capability-loader.cts accept time), but
// re-validate its path-segment shape before using it as a literal path
// component in case a future registry composer ever stops guaranteeing that.
if (!isSafeCapabilitySkillStem(capId)) return null;
const home = process.env['GSD_HOME'] || os.homedir();
const capDir = path.join(home, '.gsd', 'capabilities', capId);
const relSkillPath = path.join('skills', stem, 'SKILL.md');
// Defense-in-depth: isSafeCapabilitySkillStem already rejects separators/
// '..'/absolute stems, but re-confirm the resolved read path stays under
// this capability's own directory before ever touching the filesystem.
if (!isPathConfined(relSkillPath, capDir)) return null;
const skillPath = path.join(capDir, relSkillPath);
try {
if (!fs.statSync(skillPath).isFile()) return null;
return { capId, content: fs.readFileSync(skillPath, 'utf8') };
} catch {
return null; // missing / unreadable / corrupt entry -> skip
}
}
/**
* @param registry optional capability registry (capabilityClusters view) —
* when present, third-party capability skills are unioned into the staged
* output (bound to their declaring capId; see readInstalledCapabilitySkill).
* When absent, NOTHING third-party is staged (fail closed).
*/
function stageSkillsForRuntimeAsSkills(
srcCommandsDir: string,
resolvedProfile: ResolvedProfile,
converter: (content: string, skillName: string) => string,
prefix: string,
nested = false,
registry?: CapabilityRegistry,
): string {
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
@@ -496,6 +661,11 @@ function stageSkillsForRuntimeAsSkills(
}
}
// #2322: stems actually staged from gsd-core's OWN bundled commands/gsd dir
// this call, so the third-party fill-in pass below can enforce "first-party
// ALWAYS wins on collision" without re-deriving membership.
const firstPartyStems = new Set<string>();
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-'));
try {
const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true });
@@ -504,6 +674,7 @@ function stageSkillsForRuntimeAsSkills(
if (!entry.name.endsWith('.md')) continue;
const stem = entry.name.slice(0, -3);
if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue;
firstPartyStems.add(stem);
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
const skillName = `${prefix}${stem}`;
const converted = converter(content, skillName);
@@ -535,6 +706,54 @@ function stageSkillsForRuntimeAsSkills(
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
}
// #2322: materialize installed THIRD-PARTY capability skills, bound to
// their DECLARING capability via the registry's capabilityClusters view
// (see readInstalledCapabilitySkill — NEVER scan-and-first-match). The
// registry union (#2045) already puts every accepted-capability stem into
// a concrete resolvedProfile.skills Set, but srcCommandsDir only ever
// holds gsd-core's own bundled commands — so any stem with no first-party
// file here was silently dropped (registry says surfaced:true, nothing on
// disk) unless we fill it in from the capability's own install dir.
//
// BLOCKER 2 (#2322): `resolveProfile` short-circuits the `full` profile
// straight to the `'*'` sentinel BEFORE ever consulting a registry — the
// sentinel therefore carries no per-stem list of its own, and a bare
// `resolvedProfile.skills !== '*'` gate here skipped this ENTIRE fill-in
// pass for a `full` install regardless of what the registry declared
// (the issue's default-profile repro: `mode=full` staged zero third-party
// skills even when `mode=standard` on the SAME registry staged them
// correctly). When `resolvedProfile.skills === '*'`, the candidate stems
// are instead every stem the registry's `capabilityClusters` declares —
// mirroring the SAME unconditional union `resolveSurface` (surface.cts,
// "Issue #2045" block) already performs for its own `'*'` case. When
// `resolvedProfile.skills` is a concrete Set, the candidate stems are the
// ones `_capabilitySkillsForMode` already unioned into it (unchanged).
//
// No registry in scope at all -> stage NOTHING third-party (fail closed —
// never fall back to scanning). Nesting (#69) never applies to a
// capability skill — it was never a child of any ns-* router's
// `requires:` list — so it always lands flat at the top level, exactly
// like an unrouted first-party skill.
if (registry) {
const candidateStems: Iterable<string> =
resolvedProfile.skills === '*' ? capabilityClusterStems(registry) : resolvedProfile.skills;
for (const stem of candidateStems) {
if (firstPartyStems.has(stem)) continue; // first-party always wins
const found = readInstalledCapabilitySkill(stem, registry);
if (found === null) continue; // absent/malformed/unowned -> skip gracefully
const skillName = `${prefix}${stem}`;
if (!isPathConfined(skillName, stageDir)) continue; // defense-in-depth
const destDir = path.join(stageDir, skillName);
fs.mkdirSync(destDir, { recursive: true });
fs.writeFileSync(path.join(destDir, 'SKILL.md'), found.content);
// #2322 HIGH-3: persist the capability-owned marker so a later prune
// pass (surface.cts pruneSkillDirs) can identify — and remove — this
// directory even once the owning capability is uninstalled/unsurfaced
// and no longer appears in any registry view.
fs.writeFileSync(path.join(destDir, CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
}
}
} catch (err) {
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ }
throw err;
@@ -878,6 +1097,12 @@ export = {
parseRequires,
parseCallsAgents,
cleanupStagedSkills,
// #2322: capability-skill security seams — exported for direct unit-testing
// and for surface.cts's prune pass (CAPABILITY_SKILL_MARKER parity).
isSafeCapabilitySkillStem,
readInstalledCapabilitySkill,
capabilityClusterStems,
CAPABILITY_SKILL_MARKER,
// Back-compat / deprecated
MINIMAL_SKILL_ALLOWLIST,
isMinimalMode,

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 };
}

View File

@@ -38,6 +38,10 @@ const {
readActiveProfile,
resolveProfile,
loadSkillsManifest,
// #2322 HIGH-3: shared marker name — single source of truth with the writer
// (install-profiles.cts stageSkillsForRuntimeAsSkills) so the prune reader
// below can never drift from what the stage-time writer actually wrote.
CAPABILITY_SKILL_MARKER,
} = installProfiles;
import { CLUSTERS } from './clusters.cjs';
import type { ClusterMap } from './clusters.cjs';
@@ -426,12 +430,18 @@ function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<st
*
* Ownership criteria:
* - Non-empty prefix (e.g. 'gsd-'): dir name starts with that prefix AND
* appears in the manifest (manifest membership is required). Dirs that match
* the prefix but are NOT in the manifest are treated as user-owned and
* EITHER appears in the manifest (first-party membership) OR carries the
* persisted `CAPABILITY_SKILL_MARKER` file (#2322 HIGH-3: a third-party
* capability skill, self-certifying and independent of current registry
* state — so an uninstalled/unsurfaced capability's stale skill is still
* prunable even though it no longer appears in any registry view). Dirs
* that match the prefix but satisfy NEITHER are treated as user-owned and
* preserved — this prevents data loss for user-created gsd-* directories.
* A warning is written to stderr when such a dir is encountered.
* - Empty prefix (Hermes): dir name appears as a canonical skill stem in the
* manifest. User dirs not in the manifest are preserved.
* manifest. User dirs not in the manifest are preserved. (Hermes does not
* yet stage third-party capability skills, so the marker check does not
* apply on this path.)
* - Empty prefix without manifest, or manifest not a Map: conservative; no
* dirs are removed.
*
@@ -468,20 +478,48 @@ function pruneSkillDirs(skillsDir: string, retainedNames: Set<string>, prefix: s
// Does not match prefix at all — user-owned, preserve.
continue;
}
if (!canonicalStems) {
// No manifest available: cannot confirm ownership — preserve conservatively.
continue;
}
// #2322: an entry in THIS apply's retained set is unambiguously wanted —
// check that BEFORE the first-party-manifest-membership gate below. The
// manifest only ever knows gsd-core's own bundled stems; a materialized
// third-party capability skill (retained via the resolved profile's
// registry union, #2045/#2322) has no manifest entry at all, so without
// this early check it fell into the "unknown, preserve with warning"
// branch on EVERY apply — misreporting a live, GSD-managed capability
// skill as "user-owned or unknown" noise. This does not change any
// deletion outcome (a retained entry was always preserved — see the
// `retainedNames.has(entry)` check further below); it only short-
// circuits the ambiguous-ownership warning for entries we already know,
// this apply, are wanted.
if (retainedNames.has(entry)) continue;
// Finding 1 fix: prefix match is necessary but NOT sufficient.
// The dir must also be in the manifest to be considered GSD-owned.
// A user-created gsd-* dir that isn't in the manifest is preserved with a warning.
if (!canonicalStems.has(entry.slice(prefix.length))) {
const stem = entry.slice(prefix.length);
if (canonicalStems && canonicalStems.has(stem)) {
isGsdOwned = true;
} else if (fs.existsSync(path.join(entryPath, CAPABILITY_SKILL_MARKER))) {
// #2322 HIGH-3: not a first-party stem, but self-certified as a
// GSD-managed THIRD-PARTY capability skill via the persisted marker
// (written by install-profiles.cts stageSkillsForRuntimeAsSkills at
// stage time). Without this, an orphaned capability skill — its
// owning capability uninstalled/unsurfaced and no longer appearing in
// ANY registry view — had no manifest entry at all and fell into the
// "unknown, preserve with warning" branch below FOREVER: uninstalling
// a malicious capability never actually removed its already-staged
// instructions from the agent's context. The marker makes ownership
// self-certifying at prune time, independent of current registry
// state (or even of whether a manifest was supplied at all).
isGsdOwned = true;
} else if (!canonicalStems) {
// No manifest available and no capability marker: cannot confirm
// ownership — preserve conservatively (silent).
continue;
} else {
process.stderr.write(
`[gsd] Warning: ${entry} matches GSD prefix '${prefix}' but is not in the manifest — preserving (user-owned or unknown)\n`
);
continue;
}
isGsdOwned = true;
} else if (canonicalStems) {
// Hermes: GSD-owned iff the directory name appears in the canonical manifest.
isGsdOwned = canonicalStems.has(entry);