diff --git a/packages/kernel-test/src/endowment-globals.test.ts b/packages/kernel-test/src/endowment-globals.test.ts index 638bb2534..61f30f512 100644 --- a/packages/kernel-test/src/endowment-globals.test.ts +++ b/packages/kernel-test/src/endowment-globals.test.ts @@ -8,7 +8,7 @@ import { } from '@metamask/logger'; import type { LogEntry } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; -import type { KRef, VatId } from '@metamask/ocap-kernel'; +import type { AllowedGlobalName, KRef, VatId } from '@metamask/ocap-kernel'; import { getWorkerFile } from '@ocap/nodejs-test-workers'; import { describe, expect, it } from 'vitest'; @@ -22,8 +22,8 @@ describe('global endowments', () => { globals, allowedGlobalNames, }: { - globals: string[]; - allowedGlobalNames?: string[]; + globals: AllowedGlobalName[]; + allowedGlobalNames?: AllowedGlobalName[]; }) => { const entries: LogEntry[] = []; const logger = new Logger({ @@ -248,17 +248,22 @@ describe('global endowments', () => { ).rejects.toThrow('unknown global "TextEncoder"'); }); - it('silently drops unknown names in allowedGlobalNames without affecting valid ones', async () => { - const { kernel, entries } = await setup({ - globals: ['TextEncoder', 'TextDecoder'], - allowedGlobalNames: ['TextEncoder', 'TextDecoder', 'NotARealGlobal'], - }); - - await kernel.queueMessage(v1Root, 'testTextCodec', []); - await waitUntilQuiescent(); - - const logs = extractTestLogs(entries, vatId); - expect(logs).toContain('textCodec: hello'); + it('rejects unknown names in allowedGlobalNames at the RPC boundary', async () => { + // Callers on the typed API get a compile-time error. This test covers + // the runtime check: the `initVat` RPC struct (`AllowedGlobalNameStruct`) + // rejects any name outside the literal union, so a caller that bypasses + // the type system (e.g., JS client, cast) still cannot smuggle bad names + // through. + await expect( + setup({ + globals: ['TextEncoder', 'TextDecoder'], + allowedGlobalNames: [ + 'TextEncoder', + 'TextDecoder', + 'NotARealGlobal' as AllowedGlobalName, + ], + }), + ).rejects.toThrow(/Invalid params/u); }); }); }); diff --git a/packages/ocap-kernel/CHANGELOG.md b/packages/ocap-kernel/CHANGELOG.md index 501e1fc96..7bd4596ce 100644 --- a/packages/ocap-kernel/CHANGELOG.md +++ b/packages/ocap-kernel/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Type `VatConfig.globals` and `Kernel.make`'s `allowedGlobalNames` as `AllowedGlobalName[]` (a literal union) instead of `string[]`; unknown names are now rejected at the `initVat` RPC boundary ([#941](https://github.com/MetaMask/ocap-kernel/pull/941)) + - Exports: `AllowedGlobalName`, `AllowedGlobalNameStruct`, `MakeAllowedGlobals`, `VatEndowmentsStruct` - Bound relay hints in OCAP URLs to a maximum of 3 and cap the relay pool at 20 entries with eviction of oldest non-bootstrap relays ([#929](https://github.com/MetaMask/ocap-kernel/pull/929)) ### Fixed diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 59f1feaa3..c9cf01b07 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -35,6 +35,7 @@ import type { SystemSubclusterConfig, } from './types.ts'; import { isVatId, isRemoteId } from './types.ts'; +import type { AllowedGlobalName } from './vats/endowments.ts'; import { SubclusterManager } from './vats/SubclusterManager.ts'; import type { VatHandle } from './vats/VatHandle.ts'; import { VatManager } from './vats/VatManager.ts'; @@ -115,7 +116,7 @@ export class Kernel { keySeed?: string | undefined; mnemonic?: string | undefined; ioChannelFactory?: IOChannelFactory; - allowedGlobalNames?: string[]; + allowedGlobalNames?: AllowedGlobalName[]; } = {}, ) { this.#platformServices = platformServices; @@ -245,7 +246,7 @@ export class Kernel { mnemonic?: string | undefined; ioChannelFactory?: IOChannelFactory; systemSubclusters?: SystemSubclusterConfig[]; - allowedGlobalNames?: string[]; + allowedGlobalNames?: AllowedGlobalName[]; } = {}, ): Promise { const kernel = new Kernel(platformServices, kernelDatabase, options); diff --git a/packages/ocap-kernel/src/index.test.ts b/packages/ocap-kernel/src/index.test.ts index b86857dcd..b1bffc5be 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -5,6 +5,7 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ + 'AllowedGlobalNameStruct', 'CapDataStruct', 'ClusterConfigStruct', 'Kernel', diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 87229bc3b..8aac880bf 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -2,7 +2,11 @@ export { Kernel } from './Kernel.ts'; export { VatHandle } from './vats/VatHandle.ts'; export { VatSupervisor } from './vats/VatSupervisor.ts'; export { createDefaultEndowments } from './vats/endowments.ts'; -export type { VatEndowments } from './vats/endowments.ts'; +export type { + AllowedGlobalName, + MakeAllowedGlobals, + VatEndowments, +} from './vats/endowments.ts'; export { initTransport } from './remotes/platform/transport.ts'; export type { IOChannel, IOChannelFactory } from './io/types.ts'; export type { @@ -46,6 +50,7 @@ export { KernelStatusStruct, SubclusterStruct, } from './types.ts'; +export { AllowedGlobalNameStruct } from './vats/endowments.ts'; export { kunser, kser, kslot, krefOf } from './liveslots/kernel-marshal.ts'; export type { SlotValue } from './liveslots/kernel-marshal.ts'; export type { KernelFacet } from './kernel-facet.ts'; diff --git a/packages/ocap-kernel/src/rpc/vat/initVat.ts b/packages/ocap-kernel/src/rpc/vat/initVat.ts index 51606c8d8..8d5c067a6 100644 --- a/packages/ocap-kernel/src/rpc/vat/initVat.ts +++ b/packages/ocap-kernel/src/rpc/vat/initVat.ts @@ -11,11 +11,13 @@ import type { Infer } from '@metamask/superstruct'; import { VatDeliveryResultStruct } from './shared.ts'; import { VatConfigStruct } from '../../types.ts'; import type { VatConfig, VatDeliveryResult } from '../../types.ts'; +import { AllowedGlobalNameStruct } from '../../vats/endowments.ts'; +import type { AllowedGlobalName } from '../../vats/endowments.ts'; const paramsStruct = object({ vatConfig: VatConfigStruct, state: array(tuple([string(), string()])), - allowedGlobalNames: exactOptional(array(string())), + allowedGlobalNames: exactOptional(array(AllowedGlobalNameStruct)), }); type Params = Infer; @@ -35,7 +37,7 @@ export const initVatSpec: InitVatSpec = { export type InitVat = ( vatConfig: VatConfig, state: Map, - allowedGlobalNames: string[] | undefined, + allowedGlobalNames: AllowedGlobalName[] | undefined, ) => Promise; type InitVatHooks = { diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 29c3cee7c..3d42ddea8 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -40,6 +40,8 @@ import type { RemoteCommsOptions, } from './remotes/types.ts'; import { Fail } from './utils/assert.ts'; +import { AllowedGlobalNameStruct } from './vats/endowments.ts'; +import type { AllowedGlobalName } from './vats/endowments.ts'; /** * # Branded types @@ -588,7 +590,7 @@ export type VatConfig = UserCodeSpec & { creationOptions?: Record; parameters?: Record; platformConfig?: Partial; - globals?: string[]; + globals?: AllowedGlobalName[]; }; const UserCodeSpecStruct = union([ @@ -618,7 +620,7 @@ export const VatConfigStruct = define('VatConfig', (value) => { (!creationOptions || is(creationOptions, UnsafeJsonStruct)) && (!parameters || is(parameters, UnsafeJsonStruct)) && (!platformConfig || is(platformConfig, platformConfigStruct)) && - (!globals || is(globals, array(string()))) + (!globals || is(globals, array(AllowedGlobalNameStruct))) ); }); diff --git a/packages/ocap-kernel/src/vats/VatHandle.ts b/packages/ocap-kernel/src/vats/VatHandle.ts index 4da7f6a8d..a1c18a72c 100644 --- a/packages/ocap-kernel/src/vats/VatHandle.ts +++ b/packages/ocap-kernel/src/vats/VatHandle.ts @@ -32,6 +32,7 @@ import type { VatDeliveryResult, EndpointHandle, } from '../types.ts'; +import type { AllowedGlobalName } from './endowments.ts'; import { VatSyscall } from './VatSyscall.ts'; type MessageFromVat = JsonRpcResponse | JsonRpcNotification; @@ -45,7 +46,7 @@ type VatConstructorProps = { kernelStore: KernelStore; kernelQueue: KernelQueue; logger?: Logger | undefined; - allowedGlobalNames?: string[] | undefined; + allowedGlobalNames?: AllowedGlobalName[] | undefined; }; /** @@ -65,7 +66,7 @@ export class VatHandle implements EndpointHandle { readonly #logger: Logger | undefined; /** Optional list of allowed global names for vat endowments */ - readonly #allowedGlobalNames: string[] | undefined; + readonly #allowedGlobalNames: AllowedGlobalName[] | undefined; /** Storage holding the kernel's persistent state */ readonly #kernelStore: KernelStore; diff --git a/packages/ocap-kernel/src/vats/VatManager.ts b/packages/ocap-kernel/src/vats/VatManager.ts index d315c44dc..8382b962a 100644 --- a/packages/ocap-kernel/src/vats/VatManager.ts +++ b/packages/ocap-kernel/src/vats/VatManager.ts @@ -17,6 +17,7 @@ import type { PlatformServices, } from '../types.ts'; import { ROOT_OBJECT_VREF } from '../types.ts'; +import type { AllowedGlobalName } from './endowments.ts'; import { VatHandle } from './VatHandle.ts'; import type { PingVatResult } from '../rpc/index.ts'; @@ -25,7 +26,7 @@ type VatManagerOptions = { kernelStore: KernelStore; kernelQueue: KernelQueue; logger?: Logger; - allowedGlobalNames?: string[] | undefined; + allowedGlobalNames?: AllowedGlobalName[] | undefined; }; /** @@ -48,7 +49,7 @@ export class VatManager { readonly #logger: Logger; /** Optional list of allowed global names for vat endowments */ - readonly #allowedGlobalNames: string[] | undefined; + readonly #allowedGlobalNames: AllowedGlobalName[] | undefined; /** * Creates a new VatManager instance. diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.test.ts b/packages/ocap-kernel/src/vats/VatSupervisor.test.ts index 8b01e5aa4..5f691a1dd 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.test.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.test.ts @@ -290,7 +290,10 @@ describe('VatSupervisor', () => { expect(factory).toHaveBeenCalledTimes(1); }); - it('throws when a vat requests an unknown global', async () => { + it('rejects an unknown global at the initVat RPC boundary', async () => { + // VatConfig.globals is now typed as AllowedGlobalName[] and validated by + // AllowedGlobalNameStruct at the RPC boundary, so an unknown name is + // rejected before reaching the VatSupervisor's per-name check. const dispatch = vi.fn(); const mockFetchBlob: FetchBlob = vi.fn().mockResolvedValue({ @@ -323,7 +326,7 @@ describe('VatSupervisor', () => { expect.objectContaining({ id: 'test-init', error: expect.objectContaining({ - message: expect.stringContaining('unknown global "UnknownThing"'), + message: expect.stringContaining('Invalid params'), }), }), ); diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index 398c13eac..97dd3cf4b 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.ts @@ -21,6 +21,7 @@ import type { JsonRpcMessage } from '@metamask/kernel-utils'; import type { Logger } from '@metamask/logger'; import { serializeError } from '@metamask/rpc-errors'; import type { DuplexStream } from '@metamask/streams'; +import { assert } from '@metamask/superstruct'; import { hasProperty, isJsonRpcRequest, @@ -28,8 +29,8 @@ import { } from '@metamask/utils'; import { loadBundle } from './bundle-loader.ts'; -import { createDefaultEndowments } from './endowments.ts'; -import type { VatEndowments } from './endowments.ts'; +import { createDefaultEndowments, VatEndowmentsStruct } from './endowments.ts'; +import type { AllowedGlobalName, MakeAllowedGlobals } from './endowments.ts'; import { makeGCAndFinalize } from '../garbage-collection/gc-finalize.ts'; import { makeDummyMeterControl } from '../liveslots/meter-control.ts'; import { makeSupervisorSyscall } from '../liveslots/syscall.ts'; @@ -60,7 +61,7 @@ type SupervisorConstructorProps = { platformOptions?: Record; vatPowers?: Record | undefined; fetchBlob?: FetchBlob; - makeAllowedGlobals?: () => VatEndowments; + makeAllowedGlobals?: MakeAllowedGlobals; }; const marshal = makeMarshal(undefined, undefined, { @@ -162,10 +163,18 @@ export class VatSupervisor { this.#fetchBlob = fetchBlob ?? defaultFetchBlob; this.#platformOptions = platformOptions ?? {}; this.#makePlatform = makePlatform; - const { globals, teardown } = makeAllowedGlobals(); - // Defense in depth: custom `makeAllowedGlobals` factories may skip hardening. - this.#allowedGlobals = harden(globals); - this.#endowmentsTeardown = teardown; + const endowments = makeAllowedGlobals(); + // Defense in depth: custom `MakeAllowedGlobals` factories may return the + // wrong shape (e.g., no `teardown` callable) — assert before use so the + // failure surfaces at construction rather than during termination. + assert( + endowments, + VatEndowmentsStruct, + `makeAllowedGlobals returned an invalid VatEndowments value for vat "${id}"`, + ); + // Defense in depth: custom `MakeAllowedGlobals` factories may skip hardening. + this.#allowedGlobals = harden(endowments.globals); + this.#endowmentsTeardown = endowments.teardown; this.#rpcClient = new RpcClient( vatSyscallMethodSpecs, @@ -323,7 +332,7 @@ export class VatSupervisor { async #initVat( vatConfig: VatConfig, state: Map, - allowedGlobalNames: string[] | undefined, + allowedGlobalNames: AllowedGlobalName[] | undefined, ): Promise { if (this.#loaded) { throw Error( diff --git a/packages/ocap-kernel/src/vats/endowments.ts b/packages/ocap-kernel/src/vats/endowments.ts index 17fcf2576..33697d4e8 100644 --- a/packages/ocap-kernel/src/vats/endowments.ts +++ b/packages/ocap-kernel/src/vats/endowments.ts @@ -1,4 +1,12 @@ import { buildCommonEndowments } from '@metamask/snaps-execution-environments/endowments'; +import { + enums, + func, + object, + record, + string, + unknown, +} from '@metamask/superstruct'; /** * The names of host/Web API globals that vats may request as endowments. @@ -17,7 +25,7 @@ import { buildCommonEndowments } from '@metamask/snaps-execution-environments/en * requires adjusting {@link createDefaultEndowments} to pass the right * options through — see ocap-kernel issue #936 for the network case. */ -const ALLOWED_GLOBAL_NAMES = new Set([ +const ALLOWED_GLOBAL_NAMES = [ // Attenuated timer factories — isolated per vat, with teardown for // cancelling pending callbacks on termination. 'setTimeout', @@ -50,7 +58,20 @@ const ALLOWED_GLOBAL_NAMES = new Set([ 'btoa', 'AbortController', 'AbortSignal', -]); +] as const; + +/** + * A global name that vats may request as an endowment. Callers that accept + * this type get typo-checking at compile time, and the {@link AllowedGlobalNameStruct} + * enforces the same invariant at RPC boundaries. + */ +export type AllowedGlobalName = (typeof ALLOWED_GLOBAL_NAMES)[number]; + +export const AllowedGlobalNameStruct = enums(ALLOWED_GLOBAL_NAMES); + +const ALLOWED_GLOBAL_NAMES_SET: ReadonlySet = new Set( + ALLOWED_GLOBAL_NAMES, +); /** * The endowments produced for a single vat. @@ -69,6 +90,30 @@ export type VatEndowments = { teardown: () => Promise; }; +/** + * Shape-only validator used to guard the `VatSupervisor` boundary against + * custom `MakeAllowedGlobals` factories returning malformed values. It checks + * that `globals` is a record and `teardown` is a function; it does not and + * cannot verify that `teardown` returns a promise. + * + * The `globals` key is validated as `string` rather than {@link AllowedGlobalNameStruct} + * so factories may surface extras from upstream sources (e.g., Snaps' + * `buildCommonEndowments`) without tripping the assertion. Extras are dropped + * when a vat's config is resolved — only names in {@link ALLOWED_GLOBAL_NAMES} + * can actually be requested. + */ +export const VatEndowmentsStruct = object({ + globals: record(string(), unknown()), + teardown: func(), +}); + +/** + * Factory that produces a fresh {@link VatEndowments} for a single vat. + * Consumers supply this to a `VatSupervisor` to override the default + * endowment set (see {@link createDefaultEndowments}). + */ +export type MakeAllowedGlobals = () => VatEndowments; + /** * Build a fresh set of vat endowments from the Snaps attenuated factories, * filtered to the names in {@link ALLOWED_GLOBAL_NAMES}. Each call produces @@ -91,7 +136,7 @@ export function createDefaultEndowments(): VatEndowments { const teardowns: (() => Promise | void)[] = []; for (const { names, factory } of buildCommonEndowments()) { - if (!names.some((name) => ALLOWED_GLOBAL_NAMES.has(name))) { + if (!names.some((name) => ALLOWED_GLOBAL_NAMES_SET.has(name))) { continue; } let result; @@ -106,7 +151,7 @@ export function createDefaultEndowments(): VatEndowments { } const { teardownFunction, ...values } = result; for (const [key, value] of Object.entries(values)) { - if (ALLOWED_GLOBAL_NAMES.has(key)) { + if (ALLOWED_GLOBAL_NAMES_SET.has(key)) { globals[key] = value; } }