feat(#1681): ADR-1239 Phase C-2 — external-descriptor trust gate (configHome confinement) [slice 1] (#1806)
* feat(#1681): ADR-1239 Phase C-2 — external-descriptor trust gate (configHome confinement) [slice 1] Phase 4 slice 1. Load-time, fail-closed configHome write-confinement for installed third-party host-plugin descriptors — defense-in-depth on top of the existing opt-in/schema/consent/first-party-wins loader gates + Phase 2's install-time assertDestWithinConfigHome (#1679 AC3). - src/external-descriptor-trust.cts: isPathConfined(target, root) pure cross-platform containment primitive + assertDescriptorConfined(descriptor, configHome) — walks runtime.artifactLayout global/local destSubpaths, throws fail-closed (naming descriptor + path) on the first escape. Rejects ../escape + absolute-outside-root. Missing layout / invalid entries skipped. - tests/external-descriptor-confinement.test.cjs: 7 tests (containment primitive, benign passes, global/local/absolute escapes rejected, missing layout, invalid entries). Load-time twin of Phase 2's install-time gate — rejects malformed/escaping descriptors BEFORE consent even matters. NOT wired into loadRegistry yet (slice 2, 4 callers, medium blast radius); this ships the reusable gate + tests. Not the ADR-1577 prompt-injection breaker (separate concern, shared word trust). Proactive CI gates: ADR-457 ignores + INVENTORY-MANIFEST + injection-scan audit. All clean locally (7 tests + security + inventory + eslint 0 problems). * chore(changeset): add Changed fragment for external-descriptor trust gate (#1681)
This commit is contained in:
7
.changeset/wise-elks-caper.md
Normal file
7
.changeset/wise-elks-caper.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 1806
|
||||
---
|
||||
**Internal: external-descriptor trust gate — load-time `configHome` confinement** — `assertDescriptorConfined(descriptor, configHome)` (new `src/external-descriptor-trust.cts`) fail-closed rejects any installed third-party host-plugin descriptor whose declared `destSubpath` resolves outside the user-approved `configHome`, before its install plan runs (ADR-1239 Phase C-2 / #1681 slice 1). Defense-in-depth load-time twin of Phase 2's install-time `assertDestWithinConfigHome`. Not yet wired into the loader (slice 2). No user-facing change.
|
||||
|
||||
<!-- docs-exempt: internal security module; no user-facing doc surface until the loader wiring in slice 2 -->
|
||||
@@ -327,6 +327,7 @@
|
||||
"eval-command-router.cjs",
|
||||
"eval.cjs",
|
||||
"embedding-adapter.cjs",
|
||||
"external-descriptor-trust.cjs",
|
||||
"fallow-runner.cjs",
|
||||
"federated-config.cjs",
|
||||
"frontmatter.cjs",
|
||||
|
||||
@@ -207,6 +207,7 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/model-adapter.cjs',
|
||||
'gsd-core/bin/lib/hook-bus.cjs',
|
||||
'gsd-core/bin/lib/state-io.cjs',
|
||||
'gsd-core/bin/lib/external-descriptor-trust.cjs',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
82
src/external-descriptor-trust.cts
Normal file
82
src/external-descriptor-trust.cts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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 path-containment check (cross-platform). `target` is confined to `root`
|
||||
* iff resolving it relative to `root` yields a path equal to or under `root`.
|
||||
* Absolute paths outside `root` and `..`-escapes return false.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
84
tests/external-descriptor-confinement.test.cjs
Normal file
84
tests/external-descriptor-confinement.test.cjs
Normal file
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for the external-descriptor trust gate (ADR-1239 Phase C-2, #1681).
|
||||
* Pins: confined passes; escapes (.. / absolute) rejected fail-closed; missing
|
||||
* layout passes; the configHome-equals-root edge; non-string destSubpath skipped.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
isPathConfined,
|
||||
assertDescriptorConfined,
|
||||
} = require('../gsd-core/bin/lib/external-descriptor-trust.cjs');
|
||||
|
||||
test('isPathConfined: confined paths are true, escapes are false', () => {
|
||||
const root = path.join('/home', 'me', '.gsd');
|
||||
assert.ok(isPathConfined('skills', root), 'simple subdir is confined');
|
||||
assert.ok(isPathConfined('skills/gsd-plan.md', root), 'nested subdir is confined');
|
||||
assert.ok(isPathConfined('.', root), 'root itself is confined');
|
||||
assert.ok(!isPathConfined('../etc/passwd', root), 'parent escape is NOT confined');
|
||||
assert.ok(!isPathConfined('../../etc', root), 'multi-level escape is NOT confined');
|
||||
assert.ok(!isPathConfined('/etc/passwd', root), 'absolute path outside root is NOT confined');
|
||||
assert.ok(!isPathConfined('', root), 'empty target is NOT confined');
|
||||
assert.ok(!isPathConfined('skills', ''), 'empty root is NOT confined');
|
||||
});
|
||||
|
||||
test('assertDescriptorConfined: a benign descriptor (all destSubpaths under configHome) passes', () => {
|
||||
const desc = {
|
||||
id: 'community-host',
|
||||
runtime: { artifactLayout: {
|
||||
global: [{ destSubpath: 'skills' }, { destSubpath: 'agents' }],
|
||||
local: [{ destSubpath: 'commands' }],
|
||||
} },
|
||||
};
|
||||
assert.doesNotThrow(() => assertDescriptorConfined(desc, '/home/me/.community'));
|
||||
});
|
||||
|
||||
test('assertDescriptorConfined: a global destSubpath escape is rejected fail-closed', () => {
|
||||
const desc = {
|
||||
id: 'malicious-host',
|
||||
runtime: { artifactLayout: { global: [{ destSubpath: '../../../etc/passwd' }] } },
|
||||
};
|
||||
assert.throws(
|
||||
() => assertDescriptorConfined(desc, '/home/me/.gsd'),
|
||||
/malicious-host.*unconfined global destSubpath.*fail-closed/,
|
||||
'an escaping global destSubpath must be rejected with a fail-closed error naming the descriptor',
|
||||
);
|
||||
});
|
||||
|
||||
test('assertDescriptorConfined: a local destSubpath escape is rejected fail-closed', () => {
|
||||
const desc = {
|
||||
id: 'sneaky-host',
|
||||
runtime: { artifactLayout: { local: [{ destSubpath: '../../.ssh/authorized_keys' }] } },
|
||||
};
|
||||
assert.throws(
|
||||
() => assertDescriptorConfined(desc, '/home/me/.gsd'),
|
||||
/sneaky-host.*unconfined local destSubpath/,
|
||||
'an escaping local destSubpath must be rejected',
|
||||
);
|
||||
});
|
||||
|
||||
test('assertDescriptorConfined: an absolute destSubpath outside configHome is rejected', () => {
|
||||
const desc = {
|
||||
id: 'abs-host',
|
||||
runtime: { artifactLayout: { global: [{ destSubpath: '/etc/cron.d/evil' }] } },
|
||||
};
|
||||
assert.throws(() => assertDescriptorConfined(desc, '/home/me/.gsd'), /unconfined global destSubpath/);
|
||||
});
|
||||
|
||||
test('assertDescriptorConfined: a descriptor with no artifact layout passes (nothing to confine)', () => {
|
||||
assert.doesNotThrow(() => assertDescriptorConfined({ id: 'bare', runtime: {} }, '/home/me/.gsd'));
|
||||
assert.doesNotThrow(() => assertDescriptorConfined({ id: 'noruntime' }, '/home/me/.gsd'));
|
||||
assert.doesNotThrow(() => assertDescriptorConfined({}, '/home/me/.gsd'));
|
||||
assert.doesNotThrow(() => assertDescriptorConfined(null, '/home/me/.gsd'));
|
||||
});
|
||||
|
||||
test('assertDescriptorConfined: non-string / empty destSubpath entries are skipped (not flagged)', () => {
|
||||
const desc = {
|
||||
id: 'mixed',
|
||||
runtime: { artifactLayout: { global: [{ destSubpath: 'skills' }, { destSubpath: '' }, { destSubpath: null }, {}, { destSubpath: 'agents' }] } },
|
||||
};
|
||||
assert.doesNotThrow(() => assertDescriptorConfined(desc, '/home/me/.x'), 'valid entries pass; invalid entries skipped');
|
||||
});
|
||||
Reference in New Issue
Block a user