diff --git a/.changeset/bold-ravens-wake.md b/.changeset/bold-ravens-wake.md new file mode 100644 index 000000000..0d1b55798 --- /dev/null +++ b/.changeset/bold-ravens-wake.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 1808 +--- +**Internal: third-party descriptor loader enforces `configHome` write-confinement at load time** — `loadRegistry({includeInstalled:true, configHome})` now rejects (skip + warn, fail-closed) any installed third-party host-plugin descriptor whose declared `destSubpath` resolves outside the supplied `configHome`, before it is composed into the registry (ADR-1239 Phase C-2 / #1681 slice 2). The `configHome` option is optional and backward-compatible (omitted → no load-time check; install-time gate still bounds writes). No user-facing change for existing flows. + + diff --git a/src/capability-loader.cts b/src/capability-loader.cts index 1a1049dc3..571c2001a 100644 --- a/src/capability-loader.cts +++ b/src/capability-loader.cts @@ -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); diff --git a/tests/external-descriptor-loader-wiring.test.cjs b/tests/external-descriptor-loader-wiring.test.cjs new file mode 100644 index 000000000..73565c6c0 --- /dev/null +++ b/tests/external-descriptor-loader-wiring.test.cjs @@ -0,0 +1,75 @@ +'use strict'; +/** + * Integration test: loadRegistry wires the external-descriptor trust gate + * (ADR-1239 Phase C-2 / #1681 slice 2). When `configHome` is supplied, an + * installed overlay whose declared destSubpath escapes it is rejected + * (skip + confinement reason) and NOT composed; a confined overlay composes. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { cleanup } = require('./helpers.cjs'); +const { loadRegistry } = require('../gsd-core/bin/lib/capability-loader.cjs'); + +const HOST = '1.6.0'; + +function featureCap(id, extra) { + return { + id, role: 'feature', version: '1.0.0', title: id, description: 'overlay cap', + tier: 'standard', requires: [], engines: { gsd: '>=1.0.0' }, + runtimeCompat: { supported: ['*'], unsupported: [] }, + skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [], + ...extra, + }; +} + +// Build a temp GSD home with .gsd/capabilities//capability.json per cap. +function makeOverlayHome(caps) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-trust-')); + for (const cap of caps) { + const dir = path.join(home, '.gsd', 'capabilities', cap.id); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(cap), 'utf8'); + } + return home; +} + +test('loadRegistry configHome confinement: escaping overlay is skipped with a confinement reason', () => { + const home = makeOverlayHome([ + featureCap('confined-host', { runtime: { artifactLayout: { global: [{ destSubpath: 'skills' }] } } }), + featureCap('escape-host', { runtime: { artifactLayout: { global: [{ destSubpath: '../../../etc/passwd' }] } } }), + ]); + try { + const reg = loadRegistry({ + includeInstalled: true, gsdHome: home, cwd: home, hostVersion: HOST, + configHome: path.join(home, '.target'), + }); + const overlayIds = Object.keys(reg.capabilities || {}).filter((id) => id === 'confined-host' || id === 'escape-host'); + assert.ok(overlayIds.includes('confined-host'), 'confined overlay must be composed'); + assert.ok(!overlayIds.includes('escape-host'), 'escaping overlay must NOT be composed'); + const skips = (reg._overlay && reg._overlay.warnings) || []; + const confinementSkip = skips.find((s) => /confinement/.test(s.reason || '')); + assert.ok(confinementSkip, `an overlay must be skipped with a confinement reason; warnings=${JSON.stringify(skips)}`); + assert.match(confinementSkip.reason, /escape-host/, 'the confinement skip must name the escaping descriptor'); + } finally { + cleanup(home); + } +}); + +test('loadRegistry configHome confinement: omitted configHome = no load-time check (backward-compatible; relies on install-time gate)', () => { + // Same escaping overlay, but no configHome passed → it is NOT rejected by the load-time gate. + const home = makeOverlayHome([ + featureCap('escape-host', { runtime: { artifactLayout: { global: [{ destSubpath: '../../../etc' }] } } }), + ]); + try { + const reg = loadRegistry({ includeInstalled: true, gsdHome: home, cwd: home, hostVersion: HOST }); + const warnings = (reg._overlay && reg._overlay.warnings) || []; + const confinementSkip = warnings.find((s) => /confinement/.test(s.reason || '')); + assert.ok(!confinementSkip, 'no configHome → no load-time confinement check (backward-compatible)'); + } finally { + cleanup(home); + } +});