* 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:
@@ -414,6 +414,32 @@ try {
|
||||
_capabilityRegistry = undefined;
|
||||
}
|
||||
|
||||
// #2322 BLOCKER 2: `_capabilityRegistry` above is the FROZEN first-party registry
|
||||
// (capability-registry.cjs, built at publish time) — it never reflects an
|
||||
// INSTALLED third-party overlay capability, so a fresh `gsd install` could never
|
||||
// stage an installed third-party capability's skill regardless of registration,
|
||||
// even on the DEFAULT `--profile full`. `_installedCapabilityRegistry` composes
|
||||
// the overlay via capability-loader's `loadRegistry({includeInstalled:true})` —
|
||||
// the SAME call capability-writer.cts's `capability set --runtime` path already
|
||||
// uses — so a fresh install and a post-install `capability set` agree on
|
||||
// third-party skill availability. Used ONLY for skill-profile resolution and
|
||||
// runtime-artifact-layout staging below; `_capabilityRegistry` (frozen) remains
|
||||
// the source for gsd-core's OWN runtime/host-behavior descriptors (unaffected —
|
||||
// those are always first-party). A load failure degrades to the frozen
|
||||
// `_capabilityRegistry` (no overlay data -> no third-party skills staged; never
|
||||
// a crash and never a scan-and-guess fallback).
|
||||
let _installedCapabilityRegistry;
|
||||
try {
|
||||
const _capabilityLoader = require(path.join(_gsdLibDir, 'capability-loader.cjs'));
|
||||
_installedCapabilityRegistry = _capabilityLoader.loadRegistry({
|
||||
includeInstalled: true,
|
||||
cwd: process.cwd(),
|
||||
gsdHome: process.env['GSD_HOME'],
|
||||
});
|
||||
} catch (_) {
|
||||
_installedCapabilityRegistry = _capabilityRegistry;
|
||||
}
|
||||
|
||||
// Fail-safe floor for the reference host's #338-privacy-critical behaviors, used
|
||||
// ONLY when the first-party capability registry cannot be loaded (a broken bundle).
|
||||
// Without it, a registry-load failure would make `_hostBehaviors('claude')` return
|
||||
@@ -9944,14 +9970,24 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full';
|
||||
// Load the manifest and compute resolved profile for named profiles.
|
||||
// For --minimal/core: use an empty manifest (core profile has no transitive
|
||||
// deps) to produce a resolvedProfile with the core skill set. Registry IS
|
||||
// consulted so tier:core capability skills are included when registered.
|
||||
// deps) to produce a resolvedProfile with the core skill set. For core/
|
||||
// standard profiles, resolveProfile's `registry` arg IS consulted (via
|
||||
// _capabilitySkillsForMode) so tier:core/tier:standard capability skills are
|
||||
// unioned in when registered. #2322 correction: for the DEFAULT `full`
|
||||
// profile, resolveProfile short-circuits to the `{skills:'*'}` sentinel
|
||||
// BEFORE ever reading `registry` (there is nothing to union — '*' already
|
||||
// means "everything"), so the registry consultation that matters for `full`
|
||||
// happens LATER, at staging time (stageSkillsForRuntimeAsSkills's '*'
|
||||
// fill-in, resolveRuntimeArtifactLayout's `capabilityRegistry` param below) —
|
||||
// not here. `_installedCapabilityRegistry` (not the frozen `_capabilityRegistry`)
|
||||
// is passed so an INSTALLED third-party capability (not just a first-party
|
||||
// one) is honored on every profile, `full` included (#2322 blocker 2).
|
||||
const _commandsDir = path.join(src, 'commands', 'gsd');
|
||||
const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir);
|
||||
const _resolvedProfile = resolveProfile({
|
||||
modes: [_activeProfileName],
|
||||
manifest: _skillsManifest,
|
||||
registry: _capabilityRegistry,
|
||||
registry: _installedCapabilityRegistry,
|
||||
});
|
||||
// Unified staging function: all profiles use stageSkillsForProfile with the
|
||||
// registry-aware _resolvedProfile (ADR-857 phase 4c cutover).
|
||||
@@ -10311,7 +10347,10 @@ function install(isGlobal, runtime = DEFAULT_RUNTIME, options = {}) {
|
||||
resolveAttribution: getCommitAttribution,
|
||||
});
|
||||
} else {
|
||||
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution);
|
||||
// #2322: fallback path (adapter unavailable) — thread the composed
|
||||
// registry too, so this path stages third-party capability skills
|
||||
// identically to the primary adapter path above.
|
||||
installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile, getCommitAttribution, _installedCapabilityRegistry);
|
||||
}
|
||||
|
||||
// #1326 — Codex only: remove stale agents/openai.yaml sidecars from managed
|
||||
|
||||
Reference in New Issue
Block a user