* 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>
95 lines
4.0 KiB
TypeScript
95 lines
4.0 KiB
TypeScript
/**
|
|
* External-descriptor trust gate (ADR-1239 Phase C-2, #1681).
|
|
*
|
|
* Load-time `configHome` write-confinement for installed third-party host-plugin
|
|
* descriptors. The opt-in loader (`loadRegistry({includeInstalled:true})`) already
|
|
* applies schema validation + consent + first-party-wins + fail-closed gates;
|
|
* this adds defense-in-depth: **before** a third-party descriptor's install plan
|
|
* is ever executed, assert every destSubpath it declares resolves within the
|
|
* user-approved `configHome`. A path-escaping or malformed descriptor is
|
|
* rejected fail-closed.
|
|
*
|
|
* This is the load-time twin of Phase 2's install-time gate
|
|
* (`assertDestWithinConfigHome` in runtime-artifact-install-plan.cts, #1679 AC3).
|
|
* The two are defense-in-depth: load-time rejects malformed descriptors early
|
|
* (before consent even matters); install-time bounds the actual writes.
|
|
*
|
|
* Do NOT conflate with ADR-1577's prompt-injection circuit-breaker — separate
|
|
* concern sharing the word "trust".
|
|
*/
|
|
'use strict';
|
|
|
|
import path from 'node:path';
|
|
|
|
/**
|
|
* 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) {
|
|
return false;
|
|
}
|
|
const rootResolved = path.resolve(root);
|
|
const targetResolved = path.resolve(root, target);
|
|
const prefix = rootResolved + path.sep;
|
|
return targetResolved === rootResolved || targetResolved.startsWith(prefix);
|
|
}
|
|
|
|
export interface DescriptorArtifactKind {
|
|
destSubpath?: unknown;
|
|
}
|
|
export interface DescriptorArtifactLayout {
|
|
global?: DescriptorArtifactKind[];
|
|
local?: DescriptorArtifactKind[];
|
|
}
|
|
export interface DescriptorRuntimeBlock {
|
|
artifactLayout?: DescriptorArtifactLayout;
|
|
}
|
|
export interface DescriptorLike {
|
|
id?: string;
|
|
runtime?: DescriptorRuntimeBlock;
|
|
}
|
|
|
|
/**
|
|
* Assert every destSubpath the descriptor declares (global + local artifact
|
|
* layout) resolves within `configHome`. Throws fail-closed naming the offending
|
|
* descriptor + path on the first escape. A descriptor with no artifact layout
|
|
* passes (nothing to confine).
|
|
*/
|
|
export function assertDescriptorConfined(descriptor: DescriptorLike, configHome: string): void {
|
|
if (!descriptor || typeof descriptor !== 'object') return;
|
|
const id = typeof descriptor.id === 'string' ? descriptor.id : '<unknown>';
|
|
const layout = descriptor.runtime?.artifactLayout;
|
|
if (!layout || typeof layout !== 'object') return;
|
|
|
|
const check = (scope: 'global' | 'local', kinds: DescriptorArtifactKind[] | undefined) => {
|
|
if (!Array.isArray(kinds)) return;
|
|
for (const kind of kinds) {
|
|
const dest = kind?.destSubpath;
|
|
if (typeof dest !== 'string' || dest.length === 0) continue;
|
|
if (!isPathConfined(dest, configHome)) {
|
|
throw new Error(
|
|
`external-descriptor-trust: descriptor '${id}' declares an unconfined ${scope} destSubpath ` +
|
|
`${JSON.stringify(dest)} (resolves outside configHome ${JSON.stringify(configHome)}) — rejected fail-closed.`,
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
check('global', layout.global);
|
|
check('local', layout.local);
|
|
}
|