Files
msd-core/src/adapter-imperative.cts
Tom Boucher 23a65c4a3d 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>
2026-07-17 06:50:06 -04:00

86 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Imperative embedding adapter (ADR-1239 Phase C-1, AC2 / #1680).
*
* The engine-as-library path: an in-process host plugin calls
* `createImperativeAdapter({runtime})`, which composes the capability registry
* via `loadRegistry({includeInstalled:true})` (first-party-wins + consent +
* fail-closed gates) and binds the engine surface behind the SAME
* `HostIntegrationInterface` the declarative adapter satisfies. The adapter
* stays thin — it does NOT reimplement the loop resolver; it delegates to the
* engine + exposes the composed registry so a host (Phase 5) can bind its
* primitives (command/dispatch/model/hooks/state/artifact) to the registry's
* declared capability set.
*
* Concrete host binding (OpenCode/VS Code/pi) is deferred to Phase 5 (#1682,
* D15/D18). This slice ships the adapter + the composed-registry seam.
*
* Minimal interface (per ADR-1239 open wire-shape question): satisfies the
* same `{kind, runtime, install, uninstall}` shape as the declarative adapter,
* plus an imperative-specific `registry` accessor (the composed loadRegistry
* result). The full 6-point binding surface grows when a real host consumer
* fixes the shape.
*/
'use strict';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import installEngine = require('./install-engine.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import capabilityLoader = require('./capability-loader.cjs');
import type { HostIntegrationInterface, AdapterInstallIntent, AdapterUninstallIntent } from './embedding-adapter.cjs';
/**
* The imperative adapter: the shared contract PLUS the composed capability
* registry an in-process host binds its primitives to.
*/
export interface ImperativeAdapter extends HostIntegrationInterface {
readonly kind: 'imperative';
/** The composed capability registry (loadRegistry({includeInstalled:true})). */
readonly registry: ReturnType<typeof capabilityLoader.loadRegistry>;
}
export interface CreateImperativeAdapterOptions {
/** Optional overrides forwarded to loadRegistry (cwd, gsdHome, hostVersion). */
loadOptions?: Record<string, unknown>;
}
export function createImperativeAdapter(
{ runtime }: { runtime: string },
options: CreateImperativeAdapterOptions = {},
): ImperativeAdapter {
if (!runtime || typeof runtime !== 'string') {
throw new TypeError('createImperativeAdapter: runtime is required (non-empty string)');
}
// Compose first-party ∪ installed capability overlays with the SAME
// precedence, consent, and fail-closed-gate guarantees the CLI enforces —
// an in-process host gets identical trust semantics, not a parallel path.
const registry = capabilityLoader.loadRegistry({
includeInstalled: true,
...(options.loadOptions ?? {}),
});
return Object.freeze({
kind: 'imperative' as const,
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 {
installEngine.uninstallRuntimeArtifacts(runtime, intent.configDir, intent.scope);
},
});
}