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:
Tom Boucher
2026-06-28 12:00:43 -04:00
committed by GitHub
parent abd21b2968
commit 21b81ea068
5 changed files with 175 additions and 0 deletions

View 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 -->

View File

@@ -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",

View File

@@ -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',
],
},

View 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);
}

View 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');
});