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:
7
.changeset/bold-ravens-wake.md
Normal file
7
.changeset/bold-ravens-wake.md
Normal file
@@ -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.
|
||||
|
||||
<!-- docs-exempt: internal loader hardening; no user-facing doc surface until slice 3's MCP server -->
|
||||
@@ -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);
|
||||
|
||||
75
tests/external-descriptor-loader-wiring.test.cjs
Normal file
75
tests/external-descriptor-loader-wiring.test.cjs
Normal file
@@ -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/<id>/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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user