diff --git a/.changeset/wise-elks-caper.md b/.changeset/wise-elks-caper.md new file mode 100644 index 000000000..6fe2c999f --- /dev/null +++ b/.changeset/wise-elks-caper.md @@ -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. + + diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index bcccec64b..722d81b15 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/eslint.config.mjs b/eslint.config.mjs index 5d2fdcfc2..069bd7285 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', ], }, diff --git a/src/external-descriptor-trust.cts b/src/external-descriptor-trust.cts new file mode 100644 index 000000000..1e755310f --- /dev/null +++ b/src/external-descriptor-trust.cts @@ -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 : ''; + 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); +} diff --git a/tests/external-descriptor-confinement.test.cjs b/tests/external-descriptor-confinement.test.cjs new file mode 100644 index 000000000..913bdb6c9 --- /dev/null +++ b/tests/external-descriptor-confinement.test.cjs @@ -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'); +});