* 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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user