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