ADR-1244 Phase 4 (D5 trust + D6 upgrade/compat). capability-trust.cjs (disclosure/consent, strict_known_registries, engines+compatVersions, reserved namespace) + capability-lifecycle.cjs (install/upgrade/remove/reconcile; ledger-as-commit-point _pending intent; atomic stage-then-swap; surgical marker-isolated shared-edit strip; owner-token lock) + capability-source promote/skipEnginesGate seams + loader pending-skip + config keys. No sandbox re-derived (consent+integrity+reversibility). 6 Codex adversarial rounds + /security-review (no HIGH) + /code-review; gsd-test green both platforms; CI green. Phase 5 (#1434) wires the CLI dispatch. Closes #1433. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1004
src/capability-lifecycle.cts
Normal file
1004
src/capability-lifecycle.cts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -159,6 +159,27 @@ function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-scope ledger co-located with an overlay root (the root is `<scope>/.gsd/capabilities`,
|
||||
* so its ledger is `<scope>/.gsd-capabilities.json`) and return the ids carrying an in-flight
|
||||
* `_pending` intent — i.e. crashed/uncommitted installs or upgrades that must not be activated until
|
||||
* reconciliation completes. Never throws: a missing/invalid ledger yields an empty set.
|
||||
*/
|
||||
function pendingOverlayIds(rootDir: string): Set<string> {
|
||||
const out = new Set<string>();
|
||||
try {
|
||||
const ledgerPath = path.join(rootDir, '..', '..', '.gsd-capabilities.json');
|
||||
const parsed: unknown = JSON.parse(fs.readFileSync(ledgerPath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object') return out;
|
||||
const entries = (parsed as Record<string, unknown>)['entries'];
|
||||
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return out;
|
||||
for (const [id, entry] of Object.entries(entries as Record<string, unknown>)) {
|
||||
if (entry && typeof entry === 'object' && (entry as Record<string, unknown>)['_pending']) out.add(id);
|
||||
}
|
||||
} catch { /* missing/invalid ledger — nothing pending */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */
|
||||
function withOverlayMeta(reg: Registry, meta: OverlayMeta): Registry {
|
||||
return Object.assign({}, reg, { _overlay: meta });
|
||||
@@ -240,12 +261,22 @@ export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
|
||||
} catch {
|
||||
continue; // no overlay dir at this scope — normal
|
||||
}
|
||||
// Ids whose ledger entry carries an in-flight `_pending` intent (a crashed/uncommitted
|
||||
// install or upgrade). They are NOT yet committed, so they must not be activated — reconcile
|
||||
// will roll them forward or back. Fail OPEN (skip without a gate block): an uncommitted gate
|
||||
// is not a real installed gate. See capability-lifecycle.cts (ADR-1244 Phase 4).
|
||||
const pendingIds = pendingOverlayIds(root.dir);
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) continue;
|
||||
const id = ent.name;
|
||||
const capDir = path.join(root.dir, id);
|
||||
const manifestPath = path.join(capDir, 'capability.json');
|
||||
|
||||
if (pendingIds.has(id)) {
|
||||
warnings.push({ id, scope: root.scope, reason: 'install/upgrade in progress (uncommitted) — deferred until reconciliation' });
|
||||
continue;
|
||||
}
|
||||
|
||||
let cap: CapManifest;
|
||||
try {
|
||||
cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as CapManifest;
|
||||
|
||||
@@ -87,6 +87,21 @@ interface ResolveOptions {
|
||||
/** Expected integrity string (`sha512-<base64>`). When provided, integrity is verified
|
||||
* before any bytes are committed to the final location. */
|
||||
integrity?: string;
|
||||
/**
|
||||
* When false, stop after validation and return the staging dir WITHOUT promoting it to the
|
||||
* final capabilities location. The caller then owns the atomic stage-then-swap + ledger
|
||||
* commit ordering (ADR-1244 Phase 4 / D6 upgrade path). Defaults to true (promote in place),
|
||||
* preserving the original install behavior.
|
||||
*/
|
||||
promote?: boolean;
|
||||
/**
|
||||
* When true, SKIP the resolver's `engines.gsd` hard-throw during staging. The caller becomes
|
||||
* responsible for the engines gate. Used by capability-lifecycle so its own `checkEngines` can
|
||||
* gate the install AND surface a `compatVersions` downgrade hint (the resolver throw would
|
||||
* pre-empt that). Staging remains copy-only and is never promoted by the lifecycle when the
|
||||
* engines check fails, so nothing incompatible is ever activated. Defaults to false (throw).
|
||||
*/
|
||||
skipEnginesGate?: boolean;
|
||||
/** Injectable exec overrides for tests — keys match the shell-seam functions. */
|
||||
execOverrides?: {
|
||||
git?: (args: string[], opts?: { cwd?: string; timeout?: number }) => SpawnResult;
|
||||
@@ -302,8 +317,11 @@ function stageValidated(opts: {
|
||||
hostVersion: string;
|
||||
source: string;
|
||||
integrity: string | null;
|
||||
promote?: boolean;
|
||||
skipEnginesGate?: boolean;
|
||||
}): ResolveResult {
|
||||
const { sourceDir, id, gsdHome, hostVersion, source, integrity } = opts;
|
||||
const promote = opts.promote !== false;
|
||||
|
||||
// Safety: validate id before using it in a path.
|
||||
assertSafeId(id);
|
||||
@@ -343,9 +361,9 @@ function stageValidated(opts: {
|
||||
throw new Error('capability.json must be a JSON object');
|
||||
}
|
||||
|
||||
// engines.gsd pre-check — reject before staging finalizes.
|
||||
// engines.gsd pre-check — reject before staging finalizes (unless the caller owns the gate).
|
||||
const engines = cap['engines'];
|
||||
if (engines && typeof engines === 'object' && !Array.isArray(engines)) {
|
||||
if (!opts.skipEnginesGate && engines && typeof engines === 'object' && !Array.isArray(engines)) {
|
||||
const gsdRange = (engines as Record<string, unknown>)['gsd'];
|
||||
if (typeof gsdRange === 'string' && gsdRange) {
|
||||
if (!semverMod.semverSatisfies(hostVersion, gsdRange)) {
|
||||
@@ -380,11 +398,19 @@ function stageValidated(opts: {
|
||||
throw new Error(`Cross-capability validation failed: ${crossErrs.join('; ')}`);
|
||||
}
|
||||
|
||||
// When promote === false the caller owns the swap (ADR-1244 Phase 4 upgrade path):
|
||||
// return the validated staging dir as-is, leaving it on disk for the caller to rename.
|
||||
if (!promote) {
|
||||
const version = typeof cap['version'] === 'string' ? cap['version'] : '';
|
||||
return { id, version, stagedDir: stagingDir, integrity, source };
|
||||
}
|
||||
|
||||
// All validation passed — promote staging to final.
|
||||
// Replacement is move-aside-then-rename (not rm-then-rename): rename the old
|
||||
// bundle aside (atomic), move the new one in, restore the old one if the second
|
||||
// rename fails. This avoids leaving the capability missing on a failed swap.
|
||||
// (Fully-atomic stage-then-swap for upgrades is finished in Phase 4 / ADR-1244 D6.)
|
||||
// (The fully-atomic stage-then-swap with the ledger as commit point — for upgrades —
|
||||
// lives in capability-lifecycle.cjs and uses promote:false above.)
|
||||
if (fs.existsSync(finalDir)) {
|
||||
const backupDir = `${finalDir}.old-${process.pid}-${Date.now()}`;
|
||||
fs.renameSync(finalDir, backupDir);
|
||||
@@ -501,7 +527,7 @@ function resolveLocal(
|
||||
const id = typeof cap['id'] === 'string' ? cap['id'] : '';
|
||||
if (!id) throw new Error('capability.json missing "id" field');
|
||||
|
||||
return stageValidated({ sourceDir: absPath, id, gsdHome, hostVersion, source: parsed.raw, integrity: null });
|
||||
return stageValidated({ sourceDir: absPath, id, gsdHome, hostVersion, source: parsed.raw, integrity: null, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
|
||||
}
|
||||
|
||||
function resolveGit(
|
||||
@@ -543,7 +569,7 @@ function resolveGit(
|
||||
const id = typeof cap['id'] === 'string' ? cap['id'] : '';
|
||||
if (!id) throw new Error('capability.json missing "id" field');
|
||||
|
||||
return stageValidated({ sourceDir: cloneDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: null });
|
||||
return stageValidated({ sourceDir: cloneDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: null, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
|
||||
} finally {
|
||||
try { fs.rmSync(cloneDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
@@ -605,7 +631,7 @@ function resolveNpm(
|
||||
const id = typeof cap['id'] === 'string' ? cap['id'] : '';
|
||||
if (!id) throw new Error('capability.json missing "id" field');
|
||||
|
||||
return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: null });
|
||||
return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: null, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
|
||||
} finally {
|
||||
try { fs.rmSync(tmpPackDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
@@ -659,7 +685,7 @@ async function resolveTarball(
|
||||
const id = typeof cap['id'] === 'string' ? cap['id'] : '';
|
||||
if (!id) throw new Error('capability.json missing "id" field');
|
||||
|
||||
return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: computedIntegrity });
|
||||
return stageValidated({ sourceDir, id, gsdHome, hostVersion, source: parsed.raw, integrity: computedIntegrity, promote: opts.promote, skipEnginesGate: opts.skipEnginesGate });
|
||||
} finally {
|
||||
try { fs.rmSync(extractDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
538
src/capability-trust.cts
Normal file
538
src/capability-trust.cts
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Capability trust gate — ADR-1244 Phase 4 (Decision D5 + the compatibility half of D6).
|
||||
*
|
||||
* PURE module. It computes *what* a capability would do and *whether* policy allows it; it
|
||||
* never mutates the filesystem and never performs I/O beyond reading staged files to confirm
|
||||
* declared executable artifacts exist. The actual consent decision (yes/no) is passed in by the
|
||||
* caller — GSD has no interactive-prompt layer in lib (the runtime/CLI edge owns that), so the
|
||||
* gate stays testable and side-effect-free. See docs/explanation/the-capability-trust-model.md.
|
||||
*
|
||||
* LEAF MODULE — imports ONLY: node:fs, node:path, and ./semver-compare.cjs.
|
||||
*
|
||||
* Exports:
|
||||
* RESERVED_NAMESPACES — id prefixes third parties may not claim
|
||||
* discloseExecutableSurfaces(...) — enumerate hooks / command modules / mcpServers
|
||||
* checkReservedNamespace(id) — is this id in a reserved namespace?
|
||||
* evaluateSourceAllowed(parsed,...) — strictKnownRegistries enforcement
|
||||
* checkEngines(manifest, host) — engines.gsd hard gate + compatVersions downgrade
|
||||
* evaluateInstallTrust(args) — compose: source + namespace + engines + disclosure
|
||||
* executableSetChanged(old, new) — did the executable surface set change between versions?
|
||||
* summarizeDisclosure(disclosure) — human-readable consent-prompt lines
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const semverMod = require('./semver-compare.cjs') as {
|
||||
semverSatisfies: (version: string, range: string) => boolean;
|
||||
isSemverNewer: (a: string, b: string) => boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Id prefixes reserved for first-party / vendor capabilities. A third-party capability whose
|
||||
* id begins with any of these is rejected at install so it cannot impersonate a first-party
|
||||
* one. Match is case-insensitive on the normalized id.
|
||||
*/
|
||||
const RESERVED_NAMESPACES = ['gsd-', 'gsd-core-', 'anthropic-'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CapabilityManifest {
|
||||
id?: unknown;
|
||||
version?: unknown;
|
||||
engines?: unknown;
|
||||
compatVersions?: unknown;
|
||||
hooks?: unknown;
|
||||
commands?: unknown;
|
||||
mcpServers?: unknown;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface HookSurface {
|
||||
event: string;
|
||||
script: string;
|
||||
}
|
||||
|
||||
interface CommandModuleSurface {
|
||||
family: string;
|
||||
module: string;
|
||||
}
|
||||
|
||||
interface McpServerSurface {
|
||||
name: string;
|
||||
/** The command the server spawns (the actual executable — disclosed for honest consent). */
|
||||
command: string;
|
||||
/** Arguments passed to the command. */
|
||||
argv: string[];
|
||||
}
|
||||
|
||||
interface Disclosure {
|
||||
/** Hook scripts the capability registers (each runs as a runtime hook command). */
|
||||
hooks: HookSurface[];
|
||||
/** Command modules the capability ships (each is require()'d into the GSD CLI process). */
|
||||
commandModules: CommandModuleSurface[];
|
||||
/** MCP servers the capability declares (each spawned by the host runtime) — name AND command. */
|
||||
mcpServers: McpServerSurface[];
|
||||
/** True when the capability ships ANY executable surface (=> consent required). */
|
||||
hasExecutable: boolean;
|
||||
/**
|
||||
* Declared module/script files that were NOT found under the staged dir (defensive — a
|
||||
* manifest referencing a missing artifact is suspicious; surfaced, not silently dropped).
|
||||
* Empty when no stagedDir was supplied.
|
||||
*/
|
||||
missingArtifacts: string[];
|
||||
}
|
||||
|
||||
type StrictKnownRegistries = string[] | null | undefined;
|
||||
|
||||
interface ParsedSpec {
|
||||
kind: 'registry' | 'git' | 'npm' | 'tarball' | 'local';
|
||||
raw: string;
|
||||
target: string;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
interface SourceVerdict {
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
interface EnginesVerdict {
|
||||
/** Does the capability's *current* version run on this host? */
|
||||
compatible: boolean;
|
||||
/** The declared engines.gsd range, or null if unconstrained. */
|
||||
range: string | null;
|
||||
satisfiedBy: 'engines' | 'compatVersions' | 'unconstrained' | null;
|
||||
/** When the current version is incompatible but compatVersions names one that works. */
|
||||
downgradeTo?: string;
|
||||
}
|
||||
|
||||
interface InstallTrustArgs {
|
||||
parsed: ParsedSpec;
|
||||
manifest: CapabilityManifest;
|
||||
/** Optional staged dir — when given, declared artifacts are existence-checked. */
|
||||
stagedDir?: string;
|
||||
strictKnownRegistries?: StrictKnownRegistries;
|
||||
hostVersion: string;
|
||||
}
|
||||
|
||||
interface InstallTrustVerdict {
|
||||
/** True when no policy gate blocks the install. */
|
||||
allowed: boolean;
|
||||
/** True when the install is allowed BUT ships executable surfaces => needs consent. */
|
||||
requiresConsent: boolean;
|
||||
disclosure: Disclosure;
|
||||
engines: EnginesVerdict;
|
||||
/** Non-empty when allowed === false; each string is a human-readable block reason. */
|
||||
blockReasons: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Disclosure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function asString(v: unknown): string {
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerate every executable surface a capability manifest declares.
|
||||
*
|
||||
* Recognizes the three executable surface kinds a capability can ship:
|
||||
* - `hooks`: [{ event, script }] — scripts run as runtime hook commands
|
||||
* - `commands`:[{ family, module, router? }] — modules require()'d into the CLI process
|
||||
* - `mcpServers`: { <name>: {...} } | [{ name }] — servers spawned by the host runtime
|
||||
*
|
||||
* `mcpServers` is not a first-party capability.json field today, but a third-party manifest may
|
||||
* declare it, so the trust gate discloses it whenever present (honest disclosure over the
|
||||
* narrower first-party schema). Pure: when `stagedDir` is provided, declared script/module
|
||||
* files are existence-checked and any missing ones reported, but nothing is mutated.
|
||||
*/
|
||||
function discloseExecutableSurfaces(manifest: CapabilityManifest, stagedDir?: string): Disclosure {
|
||||
const hooks: HookSurface[] = [];
|
||||
const commandModules: CommandModuleSurface[] = [];
|
||||
const mcpServers: McpServerSurface[] = [];
|
||||
const missingArtifacts: string[] = [];
|
||||
|
||||
// hooks: [{ event, script }]
|
||||
if (Array.isArray(manifest.hooks)) {
|
||||
for (const h of manifest.hooks) {
|
||||
if (typeof h !== 'object' || h === null) continue;
|
||||
const rec = h as Record<string, unknown>;
|
||||
const script = asString(rec['script']);
|
||||
const event = asString(rec['event']);
|
||||
if (script) {
|
||||
hooks.push({ event, script });
|
||||
if (stagedDir && !artifactExists(stagedDir, script)) {
|
||||
missingArtifacts.push(script);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// commands: [{ family, module, router? }]
|
||||
if (Array.isArray(manifest.commands)) {
|
||||
for (const c of manifest.commands) {
|
||||
if (typeof c !== 'object' || c === null) continue;
|
||||
const rec = c as Record<string, unknown>;
|
||||
const moduleName = asString(rec['module']);
|
||||
const family = asString(rec['family']);
|
||||
if (moduleName) {
|
||||
commandModules.push({ family, module: moduleName });
|
||||
if (stagedDir && !artifactExists(stagedDir, moduleName)) {
|
||||
missingArtifacts.push(moduleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mcpServers: object map { name: { command, args } } OR array [{ name, command, args }]
|
||||
// (or array [{ name, config: { command, args } }]). Capture the COMMAND, not just the name —
|
||||
// the command is the executable that actually runs, and consent must disclose it (Codex R1 H1).
|
||||
if (manifest.mcpServers && typeof manifest.mcpServers === 'object') {
|
||||
const pushServer = (name: string, config: unknown): void => {
|
||||
if (!name) return;
|
||||
const cfg = (typeof config === 'object' && config !== null) ? (config as Record<string, unknown>) : {};
|
||||
const command = asString(cfg['command']);
|
||||
const argv = Array.isArray(cfg['args']) ? cfg['args'].filter((a): a is string => typeof a === 'string') : [];
|
||||
mcpServers.push({ name, command, argv });
|
||||
};
|
||||
if (Array.isArray(manifest.mcpServers)) {
|
||||
for (const s of manifest.mcpServers) {
|
||||
if (typeof s === 'object' && s !== null) {
|
||||
const rec = s as Record<string, unknown>;
|
||||
pushServer(asString(rec['name']), rec['config'] ?? rec);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const [name, config] of Object.entries(manifest.mcpServers as Record<string, unknown>)) {
|
||||
pushServer(name, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasExecutable = hooks.length > 0 || commandModules.length > 0 || mcpServers.length > 0;
|
||||
return { hooks, commandModules, mcpServers, hasExecutable, missingArtifacts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Existence-check a manifest-declared artifact path under stagedDir, refusing to follow it
|
||||
* outside the staged root (defense against `../` traversal in a hostile manifest).
|
||||
*/
|
||||
function artifactExists(stagedDir: string, relPath: string): boolean {
|
||||
if (!relPath || path.isAbsolute(relPath) || relPath.split(/[/\\]/).includes('..')) {
|
||||
// A traversal/absolute artifact path is treated as "not present" (and is independently
|
||||
// rejected by the validator / lifecycle); never resolve it.
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return fs.existsSync(path.join(stagedDir, relPath));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Namespace reservation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Is `id` in a reserved namespace? Reserved prefixes are first-party/vendor-only so a
|
||||
* third-party capability cannot impersonate a first-party one.
|
||||
*/
|
||||
function checkReservedNamespace(id: unknown): { reserved: boolean; namespace: string | null } {
|
||||
if (typeof id !== 'string' || !id) return { reserved: false, namespace: null };
|
||||
const lower = id.toLowerCase();
|
||||
for (const ns of RESERVED_NAMESPACES) {
|
||||
if (lower.startsWith(ns)) return { reserved: true, namespace: ns };
|
||||
}
|
||||
return { reserved: false, namespace: null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// strictKnownRegistries enforcement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract the host of a URL-bearing spec for host-based allowlist matching. Returns '' when no
|
||||
* host can be parsed (caller treats '' as non-matching).
|
||||
*/
|
||||
function specHost(parsed: ParsedSpec): string {
|
||||
// git specs may be scp-style (git@host:path) or URL-style; tarball/registry are URLs.
|
||||
const raw = parsed.target || parsed.raw || '';
|
||||
const scp = /^[^@/]+@([^:]+):/.exec(raw);
|
||||
if (scp) return scp[1].toLowerCase();
|
||||
try {
|
||||
return new URL(raw).hostname.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `host` equals an allowlist entry or is a subdomain of it. Host-based, NOT substring:
|
||||
* `github.com` matches `github.com` and `api.github.com`, never `evilgithub.com`.
|
||||
*/
|
||||
function hostMatchesAllowlist(host: string, list: string[]): boolean {
|
||||
if (!host) return false;
|
||||
for (const entryRaw of list) {
|
||||
const entry = typeof entryRaw === 'string' ? entryRaw.trim().toLowerCase() : '';
|
||||
if (!entry) continue;
|
||||
if (host === entry || host.endsWith('.' + entry)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a Windows/UNC network path. Matches any two leading slash-or-backslash characters
|
||||
* (`\\`, `//`, and the mixed `\/` / `/\` forms Windows also treats as UNC-absolute).
|
||||
*/
|
||||
function isUncPath(p: string): boolean {
|
||||
return /^[\\/]{2}/.test(p);
|
||||
}
|
||||
|
||||
/** Extract the server host of a UNC path (`\\server\share` -> `server`). */
|
||||
function uncHost(p: string): string {
|
||||
const m = /^[\\/]{2}([^\\/]+)/.exec(p);
|
||||
return m ? m[1].toLowerCase() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the `capabilities.strict_known_registries` policy to a parsed spec.
|
||||
*
|
||||
* undefined/null -> permissive: external installs allowed (consent gate still applies).
|
||||
* [] -> lockdown: all EXTERNAL installs blocked (local-only).
|
||||
* non-empty list -> allowlist: only sources whose host matches an entry are allowed.
|
||||
* anything else -> FAIL CLOSED: a malformed policy value blocks the install.
|
||||
*
|
||||
* Local (filesystem) sources are never "external" and are always allowed — EXCEPT a UNC network
|
||||
* path (`\\server\share`), which is remote despite parsing as an "absolute"/local-kind spec and is
|
||||
* therefore subject to the policy.
|
||||
*/
|
||||
function evaluateSourceAllowed(parsed: ParsedSpec, strict: StrictKnownRegistries): SourceVerdict {
|
||||
const target = parsed.target || parsed.raw || '';
|
||||
const unc = parsed.kind === 'local' && isUncPath(target);
|
||||
if (parsed.kind === 'local' && !unc) return { allowed: true, reason: null };
|
||||
|
||||
if (strict === undefined || strict === null) return { allowed: true, reason: null };
|
||||
if (!Array.isArray(strict)) {
|
||||
// A security policy must never be silently ignored when it is the wrong type (e.g. a
|
||||
// string `"[]"` from a hand-edited config). Fail closed.
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
'capabilities.strict_known_registries must be an array (or null/unset); refusing the install on a malformed policy value',
|
||||
};
|
||||
}
|
||||
|
||||
if (strict.length === 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
'capabilities.strict_known_registries is [] — all external capability installs are disabled. ' +
|
||||
'Install from a local path, or add an allowed host to the list.',
|
||||
};
|
||||
}
|
||||
|
||||
// npm specs carry no host; the "registry" is npm itself. Treat the allowlist token "npm" as
|
||||
// permitting the npm source kind.
|
||||
if (parsed.kind === 'npm') {
|
||||
if (strict.some((e) => typeof e === 'string' && e.trim().toLowerCase() === 'npm')) {
|
||||
return { allowed: true, reason: null };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `npm source is not in capabilities.strict_known_registries (add "npm" to allow it)`,
|
||||
};
|
||||
}
|
||||
|
||||
const host = unc ? uncHost(target) : specHost(parsed);
|
||||
if (hostMatchesAllowlist(host, strict)) return { allowed: true, reason: null };
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `source host "${host || '(unparseable)'}" is not in capabilities.strict_known_registries`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// engines.gsd hard gate + compatVersions downgrade
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Hard-gate a manifest against the running host version via engines.gsd, consulting
|
||||
* compatVersions for a graceful-downgrade target when the current version is incompatible.
|
||||
*/
|
||||
function checkEngines(manifest: CapabilityManifest, hostVersion: string): EnginesVerdict {
|
||||
const engines = manifest.engines;
|
||||
let range: string | null = null;
|
||||
if (engines && typeof engines === 'object' && !Array.isArray(engines)) {
|
||||
const g = (engines as Record<string, unknown>)['gsd'];
|
||||
if (typeof g === 'string' && g) range = g;
|
||||
}
|
||||
|
||||
if (!range) return { compatible: true, range: null, satisfiedBy: 'unconstrained' };
|
||||
|
||||
if (semverMod.semverSatisfies(hostVersion, range)) {
|
||||
return { compatible: true, range, satisfiedBy: 'engines' };
|
||||
}
|
||||
|
||||
// Current version is incompatible — look for a compatVersions entry that works, picking the
|
||||
// newest such capability version (best graceful downgrade).
|
||||
const compat = manifest.compatVersions;
|
||||
let best: string | undefined;
|
||||
if (compat && typeof compat === 'object' && !Array.isArray(compat)) {
|
||||
for (const [capVer, gsdRange] of Object.entries(compat as Record<string, unknown>)) {
|
||||
if (typeof gsdRange !== 'string' || !gsdRange) continue;
|
||||
if (!semverMod.semverSatisfies(hostVersion, gsdRange)) continue;
|
||||
if (best === undefined || semverMod.isSemverNewer(capVer, best)) best = capVer;
|
||||
}
|
||||
}
|
||||
|
||||
if (best !== undefined) {
|
||||
return { compatible: false, range, satisfiedBy: 'compatVersions', downgradeTo: best };
|
||||
}
|
||||
return { compatible: false, range, satisfiedBy: null };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite install verdict
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compose the full install trust verdict: source policy + reserved-namespace + engines gate +
|
||||
* executable-surface disclosure. `allowed` is true only when no gate blocks; `requiresConsent`
|
||||
* is true when allowed AND the capability ships any executable surface.
|
||||
*
|
||||
* engines.gsd is also enforced inside resolveCapabilitySource at resolve time; re-checking here
|
||||
* is defense-in-depth and lets callers surface a compatVersions downgrade hint.
|
||||
*/
|
||||
function evaluateInstallTrust(args: InstallTrustArgs): InstallTrustVerdict {
|
||||
const { parsed, manifest, stagedDir, strictKnownRegistries, hostVersion } = args;
|
||||
const blockReasons: string[] = [];
|
||||
|
||||
const src = evaluateSourceAllowed(parsed, strictKnownRegistries);
|
||||
if (!src.allowed && src.reason) blockReasons.push(src.reason);
|
||||
|
||||
const ns = checkReservedNamespace(manifest.id);
|
||||
if (ns.reserved) {
|
||||
blockReasons.push(
|
||||
`capability id "${asString(manifest.id)}" uses the reserved namespace "${ns.namespace}" — ` +
|
||||
'reserved for first-party capabilities',
|
||||
);
|
||||
}
|
||||
|
||||
const engines = checkEngines(manifest, hostVersion);
|
||||
if (!engines.compatible) {
|
||||
const hint = engines.downgradeTo
|
||||
? ` (compatVersions offers ${engines.downgradeTo} for this host)`
|
||||
: '';
|
||||
blockReasons.push(
|
||||
`capability requires engines.gsd "${engines.range}" but host is ${hostVersion}${hint}`,
|
||||
);
|
||||
}
|
||||
|
||||
const disclosure = discloseExecutableSurfaces(manifest, stagedDir);
|
||||
|
||||
// A manifest that declares a hook script or command module NOT present in the staged bundle
|
||||
// (missing, or escaping the bundle via an absolute/`..` path) is rejected: such an artifact
|
||||
// would run from outside the integrity-pinned, reversible install root. Only enforced when a
|
||||
// stagedDir was provided to existence-check against.
|
||||
if (stagedDir && disclosure.missingArtifacts.length > 0) {
|
||||
blockReasons.push(
|
||||
`capability declares executable artifacts not present in the staged bundle (or escaping it): ${disclosure.missingArtifacts.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowed = blockReasons.length === 0;
|
||||
const requiresConsent = allowed && disclosure.hasExecutable;
|
||||
return { allowed, requiresConsent, disclosure, engines, blockReasons };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Executable-set change detection (auto-update re-prompt trigger)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function disclosureSignature(d: Disclosure): string {
|
||||
const hooks = d.hooks.map((h) => `hook:${h.event}:${h.script}`).sort();
|
||||
const mods = d.commandModules.map((m) => `mod:${m.family}:${m.module}`).sort();
|
||||
// Include the command + argv so a version that keeps the server NAME but swaps the executable
|
||||
// it runs is detected as a changed surface (forces re-consent). argv is JSON-encoded (not
|
||||
// space-joined) so an argument-boundary change (['a b'] vs ['a','b']) is still a change.
|
||||
const mcp = d.mcpServers.map((s) => `mcp:${s.name}:${s.command}:${JSON.stringify(s.argv)}`).sort();
|
||||
return JSON.stringify([hooks, mods, mcp]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the executable surface set change between two versions? Auto-update must re-prompt for
|
||||
* consent when it did (the user consented to one set of executable surfaces, not another).
|
||||
*/
|
||||
function executableSetChanged(oldD: Disclosure, newD: Disclosure): boolean {
|
||||
return disclosureSignature(oldD) !== disclosureSignature(newD);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human-readable consent prompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a disclosure as consent-prompt lines. Returned as an array so the CLI/runtime edge can
|
||||
* format it; the lib never writes to stdout.
|
||||
*/
|
||||
function summarizeDisclosure(disclosure: Disclosure): string[] {
|
||||
const lines: string[] = [];
|
||||
if (!disclosure.hasExecutable) {
|
||||
lines.push('This capability ships no executable surfaces (declarative only).');
|
||||
return lines;
|
||||
}
|
||||
lines.push('This capability ships executable surfaces that will run in your agent runtime:');
|
||||
if (disclosure.hooks.length > 0) {
|
||||
lines.push(` hooks (${disclosure.hooks.length}): run as runtime hook commands`);
|
||||
for (const h of disclosure.hooks) {
|
||||
lines.push(` - ${h.event || '(event?)'} -> ${h.script}`);
|
||||
}
|
||||
}
|
||||
if (disclosure.commandModules.length > 0) {
|
||||
lines.push(
|
||||
` command modules (${disclosure.commandModules.length}): require()'d into the GSD CLI process`,
|
||||
);
|
||||
for (const m of disclosure.commandModules) {
|
||||
lines.push(` - ${m.family || '(family?)'} -> ${m.module}`);
|
||||
}
|
||||
}
|
||||
if (disclosure.mcpServers.length > 0) {
|
||||
lines.push(` MCP servers (${disclosure.mcpServers.length}): spawned by the host runtime`);
|
||||
for (const s of disclosure.mcpServers) {
|
||||
const cmd = [s.command, ...s.argv].filter(Boolean).join(' ');
|
||||
lines.push(` - ${s.name} -> ${cmd || '(no command declared)'}`);
|
||||
}
|
||||
}
|
||||
if (disclosure.missingArtifacts.length > 0) {
|
||||
lines.push(' WARNING — declared artifacts not found in the staged bundle:');
|
||||
for (const a of disclosure.missingArtifacts) {
|
||||
lines.push(` - ${a}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export = {
|
||||
RESERVED_NAMESPACES,
|
||||
discloseExecutableSurfaces,
|
||||
checkReservedNamespace,
|
||||
evaluateSourceAllowed,
|
||||
checkEngines,
|
||||
evaluateInstallTrust,
|
||||
executableSetChanged,
|
||||
summarizeDisclosure,
|
||||
};
|
||||
Reference in New Issue
Block a user