feat(#1681): ADR-1239 Phase C-2 — wire trust gate into loadRegistry (configHome confinement) [slice 2] (#1808)

* feat(#1681): ADR-1239 Phase C-2 — wire trust gate into loadRegistry (configHome confinement) [slice 2]

Phase 4 slice 2. loadRegistry({includeInstalled:true, configHome}) now rejects
(skip + warn, fail-closed) any installed third-party descriptor whose declared
destSubpath resolves outside the supplied configHome, BEFORE it is composed.

- src/capability-loader.cts: LoadRegistryOptions.configHome?:string (optional,
  backward-compatible). Require external-descriptor-trust.cjs (typed). Before
  overlayCaps.push(cap), if configHome set, assertDescriptorConfined(cap,
  configHome) — on throw, skip('configHome confinement rejected: ...') +
  continue. Fail-closed via the loader's existing per-candidate skip semantics.
- tests/external-descriptor-loader-wiring.test.cjs: integration test — escaping
  overlay skipped with confinement reason when configHome set; confined overlay
  composes; omitted configHome = no load-time check (backward-compatible).

Defense-in-depth with Phase 2: load-time rejects malformed descriptors early
(this slice); install-time assertDestWithinConfigHome bounds actual writes
(#1679 AC3). Existing capability-loader.test.cjs 54/54 (no regression —
additive optional option). Companion MCP server -> slice 3.

* chore(changeset): add Changed fragment for loadRegistry configHome confinement wiring (#1681)
This commit is contained in:
Tom Boucher
2026-06-28 12:25:02 -04:00
committed by GitHub
parent 21b81ea068
commit d2518e142d
3 changed files with 108 additions and 0 deletions

View File

@@ -101,6 +101,14 @@ export interface LoadRegistryOptions {
gsdHome?: string;
/** Override the running GSD version used for engines.gsd satisfaction. */
hostVersion?: string;
/**
* Optional configHome root for load-time write-confinement of installed
* third-party descriptors (ADR-1239 Phase C-2 / #1681). When set, each
* installed overlay's declared destSubpaths must resolve within this root or
* the descriptor is rejected fail-closed (skip + warn). Omit to rely on the
* install-time gate only (backward-compatible).
*/
configHome?: string;
}
export interface OverlaySkip {
@@ -473,6 +481,10 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
const ledgerMod: LedgerModule = require('./capability-ledger.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const consentMod: ConsentModule = require('./capability-consent.cjs');
// ADR-1239 Phase C-2 (#1681): load-time configHome confinement for installed
// third-party descriptors. Accessed via module ref for stub compatibility.
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const externalDescriptorTrust: { assertDescriptorConfined(descriptor: unknown, configHome: string): void; isPathConfined(target: string, root: string): boolean } = require('./external-descriptor-trust.cjs');
const cwd = options.cwd || process.cwd();
const hostVersion = options.hostVersion || readHostVersion();
@@ -748,6 +760,20 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
continue;
}
// ADR-1239 Phase C-2 (#1681): load-time configHome confinement — reject
// (skip + warn) any installed third-party descriptor whose declared
// destSubpath escapes the user-approved configHome, BEFORE it is composed.
// Defense-in-depth on top of the install-time gate (#1679 AC3).
if (typeof options.configHome === 'string' && options.configHome.length > 0) {
try {
externalDescriptorTrust.assertDescriptorConfined(cap, options.configHome);
} catch (confineErr) {
acceptedMap.delete(id);
skip('configHome confinement rejected: ' + errMessage(confineErr));
continue;
}
}
// Accepted.
overlayCaps.push(cap);
acceptedIds.add(id);