* 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>
86 lines
3.8 KiB
TypeScript
86 lines
3.8 KiB
TypeScript
/**
|
||
* 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);
|
||
},
|
||
});
|
||
}
|