diff --git a/packages/kernel-test/src/endowment-globals.test.ts b/packages/kernel-test/src/endowment-globals.test.ts new file mode 100644 index 0000000000..050d3782bc --- /dev/null +++ b/packages/kernel-test/src/endowment-globals.test.ts @@ -0,0 +1,196 @@ +import { NodejsPlatformServices } from '@metamask/kernel-node-runtime'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { + Logger, + makeConsoleTransport, + makeArrayTransport, +} from '@metamask/logger'; +import type { LogEntry } from '@metamask/logger'; +import { Kernel } from '@metamask/ocap-kernel'; +import type { KRef, VatId } from '@metamask/ocap-kernel'; +import { getWorkerFile } from '@ocap/nodejs-test-workers'; +import { describe, expect, it } from 'vitest'; + +import { extractTestLogs, getBundleSpec } from './utils.ts'; + +describe('global endowments', () => { + const vatId: VatId = 'v1'; + const v1Root: KRef = 'ko4'; + + const setup = async ({ + globals, + allowedGlobalNames, + }: { + globals: string[]; + allowedGlobalNames?: string[]; + }) => { + const entries: LogEntry[] = []; + const logger = new Logger({ + transports: [makeConsoleTransport(), makeArrayTransport(entries)], + }); + const database = await makeSQLKernelDatabase({}); + const platformServices = new NodejsPlatformServices({ + logger: logger.subLogger({ tags: ['vat-worker-manager'] }), + workerFilePath: getWorkerFile('mock-fetch'), + }); + const kernel = await Kernel.make(platformServices, database, { + resetStorage: true, + logger, + allowedGlobalNames, + }); + + await kernel.launchSubcluster({ + bootstrap: 'main', + vats: { + main: { + bundleSpec: getBundleSpec('endowment-globals'), + parameters: {}, + globals, + }, + }, + }); + await waitUntilQuiescent(); + + return { kernel, entries }; + }; + + it('can use TextEncoder and TextDecoder', async () => { + const { kernel, entries } = await setup({ + globals: ['TextEncoder', 'TextDecoder'], + }); + + await kernel.queueMessage(v1Root, 'testTextCodec', []); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain('textCodec: hello'); + }); + + it('can use URL and URLSearchParams', async () => { + const { kernel, entries } = await setup({ + globals: ['URL', 'URLSearchParams'], + }); + + await kernel.queueMessage(v1Root, 'testUrl', []); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain('url: /path params: 10'); + }); + + it('can use atob and btoa', async () => { + const { kernel, entries } = await setup({ globals: ['atob', 'btoa'] }); + + await kernel.queueMessage(v1Root, 'testBase64', []); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain('base64: hello world'); + }); + + it('can use AbortController and AbortSignal', async () => { + const { kernel, entries } = await setup({ + globals: ['AbortController', 'AbortSignal'], + }); + + await kernel.queueMessage(v1Root, 'testAbort', []); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain('abort: before=false after=true'); + }); + + it('can use setTimeout and clearTimeout', async () => { + const { kernel, entries } = await setup({ + globals: ['setTimeout', 'clearTimeout'], + }); + + await kernel.queueMessage(v1Root, 'testTimers', []); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain('timer: fired'); + }); + + it('can use real Date (not tamed)', async () => { + const { kernel, entries } = await setup({ globals: ['Date'] }); + + await kernel.queueMessage(v1Root, 'testDate', []); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain('date: isReal=true'); + }); + + describe('host APIs are absent when not endowed', () => { + // These are Web/host APIs that are NOT JS intrinsics — they should + // be genuinely absent from a SES compartment unless explicitly endowed. + it.each([ + 'TextEncoder', + 'TextDecoder', + 'URL', + 'URLSearchParams', + 'atob', + 'btoa', + 'AbortController', + 'AbortSignal', + 'setTimeout', + 'clearTimeout', + ])('does not have %s without endowing it', async (name) => { + // Launch with no globals at all + const { kernel, entries } = await setup({ globals: [] }); + + await kernel.queueMessage(v1Root, 'checkGlobal', [name]); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain(`checkGlobal: ${name}=false`); + }); + + it('throws when calling tamed Date.now without endowing Date', async () => { + const { kernel } = await setup({ globals: [] }); + + await expect(kernel.queueMessage(v1Root, 'testDate', [])).rejects.toThrow( + 'secure mode', + ); + }); + }); + + describe('kernel-level allowedGlobalNames restriction', () => { + it('throws when a vat requests a global excluded by the kernel', async () => { + // Kernel only allows TextEncoder/TextDecoder — vat also requests URL + await expect( + setup({ + globals: ['TextEncoder', 'TextDecoder', 'URL'], + allowedGlobalNames: ['TextEncoder', 'TextDecoder'], + }), + ).rejects.toThrow('unknown global "URL"'); + }); + + it('initializes when all vat globals are within allowedGlobalNames', async () => { + const { kernel, entries } = await setup({ + globals: ['TextEncoder', 'TextDecoder'], + allowedGlobalNames: ['TextEncoder', 'TextDecoder'], + }); + + await kernel.queueMessage(v1Root, 'testTextCodec', []); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain('textCodec: hello'); + }); + + it('allows all globals when allowedGlobalNames is omitted', async () => { + const { kernel, entries } = await setup({ + globals: ['URL', 'URLSearchParams'], + }); + + await kernel.queueMessage(v1Root, 'testUrl', []); + await waitUntilQuiescent(); + + const logs = extractTestLogs(entries, vatId); + expect(logs).toContain('url: /path params: 10'); + }); + }); +}); diff --git a/packages/kernel-test/src/vats/endowment-globals.ts b/packages/kernel-test/src/vats/endowment-globals.ts new file mode 100644 index 0000000000..88a4f07b8d --- /dev/null +++ b/packages/kernel-test/src/vats/endowment-globals.ts @@ -0,0 +1,84 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +import { unwrapTestLogger } from '../test-powers.ts'; +import type { TestPowers } from '../test-powers.ts'; + +/** + * Build a root object for a vat that exercises global endowments. + * + * @param vatPowers - The powers of the vat. + * @returns The root object. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function buildRootObject(vatPowers: TestPowers) { + const tlog = unwrapTestLogger(vatPowers, 'endowment-globals'); + + tlog('buildRootObject'); + + const root = makeDefaultExo('root', { + bootstrap: () => { + tlog('bootstrap'); + }, + + testTextCodec: () => { + const encoder = new TextEncoder(); + const encoded = encoder.encode('hello'); + const decoder = new TextDecoder(); + const decoded = decoder.decode(encoded); + tlog(`textCodec: ${decoded}`); + return decoded; + }, + + testUrl: () => { + const url = new URL('https://example.com/path?a=1'); + url.searchParams.set('b', '2'); + const params = new URLSearchParams('x=10&y=20'); + tlog(`url: ${url.pathname} params: ${params.get('x')}`); + return url.toString(); + }, + + testBase64: () => { + const encoded = btoa('hello world'); + const decoded = atob(encoded); + tlog(`base64: ${decoded}`); + return decoded; + }, + + testAbort: () => { + const controller = new AbortController(); + const { signal } = controller; + const { aborted } = signal; + controller.abort('test reason'); + tlog(`abort: before=${String(aborted)} after=${String(signal.aborted)}`); + return signal.aborted; + }, + + testTimers: async () => { + return new Promise((resolve) => { + setTimeout(() => { + tlog('timer: fired'); + resolve('fired'); + }, 10); + }); + }, + + testDate: () => { + const now = Date.now(); + const isReal = !Number.isNaN(now) && now > 0; + tlog(`date: isReal=${String(isReal)}`); + return isReal; + }, + + checkGlobal: (name: string) => { + // In a SES compartment, globalThis points to the compartment's own + // global object, so this correctly detects whether an endowment was + // provided. Intrinsics (e.g. ArrayBuffer) are always present; + // host/Web APIs (e.g. TextEncoder) are only present if endowed. + const exists = name in globalThis; + tlog(`checkGlobal: ${name}=${String(exists)}`); + return exists; + }, + }); + + return root; +} diff --git a/packages/ocap-kernel/CHANGELOG.md b/packages/ocap-kernel/CHANGELOG.md index e3d26e6726..0f43434a7d 100644 --- a/packages/ocap-kernel/CHANGELOG.md +++ b/packages/ocap-kernel/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Make vat global allowlist configurable and expand available endowments ([#933](https://github.com/MetaMask/ocap-kernel/pull/933)) + - Export `DEFAULT_ALLOWED_GLOBALS` with `URL`, `URLSearchParams`, `atob`, `btoa`, `AbortController`, and `AbortSignal` in addition to the existing globals + - Accept optional `allowedGlobals` on `VatSupervisor` for custom allowlists + - Log a warning when a vat requests an unknown global + ### Changed - 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)) diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 44978e0da3..e6df93e02b 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -103,6 +103,7 @@ export class Kernel { * @param options.keySeed - Optional seed for libp2p key generation. * @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity. * @param options.ioChannelFactory - Optional factory for creating IO channels. + * @param options.allowedGlobalNames - Optional list of allowed global names for vat endowments. */ // eslint-disable-next-line no-restricted-syntax private constructor( @@ -114,6 +115,7 @@ export class Kernel { keySeed?: string | undefined; mnemonic?: string | undefined; ioChannelFactory?: IOChannelFactory; + allowedGlobalNames?: string[]; } = {}, ) { this.#platformServices = platformServices; @@ -145,6 +147,7 @@ export class Kernel { kernelStore: this.#kernelStore, kernelQueue: this.#kernelQueue, logger: this.#logger.subLogger({ tags: ['VatManager'] }), + allowedGlobalNames: options.allowedGlobalNames, }); this.#remoteManager = new RemoteManager({ @@ -229,6 +232,7 @@ export class Kernel { * @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity. * @param options.ioChannelFactory - Optional factory for creating IO channels. * @param options.systemSubclusters - Optional array of system subcluster configurations. + * @param options.allowedGlobalNames - Optional list of allowed global names for vat endowments. When set, only these names from DEFAULT_ALLOWED_GLOBALS are available to vats. * @returns A promise for the new kernel instance. */ static async make( @@ -241,6 +245,7 @@ export class Kernel { mnemonic?: string | undefined; ioChannelFactory?: IOChannelFactory; systemSubclusters?: SystemSubclusterConfig[]; + allowedGlobalNames?: string[]; } = {}, ): 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 8161df04cf..ceb8a8c95c 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -7,6 +7,7 @@ describe('index', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ 'CapDataStruct', 'ClusterConfigStruct', + 'DEFAULT_ALLOWED_GLOBALS', 'Kernel', 'KernelStatusStruct', 'SubclusterStruct', diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 246ad3aeb3..d1075ea61b 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -1,6 +1,7 @@ export { Kernel } from './Kernel.ts'; export { VatHandle } from './vats/VatHandle.ts'; export { VatSupervisor } from './vats/VatSupervisor.ts'; +export { DEFAULT_ALLOWED_GLOBALS } from './vats/endowments.ts'; export { initTransport } from './remotes/platform/transport.ts'; export type { IOChannel, IOChannelFactory } from './io/types.ts'; export type { diff --git a/packages/ocap-kernel/src/rpc/vat/initVat.ts b/packages/ocap-kernel/src/rpc/vat/initVat.ts index e46a124c7f..51606c8d8d 100644 --- a/packages/ocap-kernel/src/rpc/vat/initVat.ts +++ b/packages/ocap-kernel/src/rpc/vat/initVat.ts @@ -1,5 +1,11 @@ import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; -import { array, object, string, tuple } from '@metamask/superstruct'; +import { + array, + exactOptional, + object, + string, + tuple, +} from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; import { VatDeliveryResultStruct } from './shared.ts'; @@ -9,6 +15,7 @@ import type { VatConfig, VatDeliveryResult } from '../../types.ts'; const paramsStruct = object({ vatConfig: VatConfigStruct, state: array(tuple([string(), string()])), + allowedGlobalNames: exactOptional(array(string())), }); type Params = Infer; @@ -28,6 +35,7 @@ export const initVatSpec: InitVatSpec = { export type InitVat = ( vatConfig: VatConfig, state: Map, + allowedGlobalNames: string[] | undefined, ) => Promise; type InitVatHooks = { @@ -45,6 +53,10 @@ export const initVatHandler: InitVatHandler = { ...initVatSpec, hooks: { initVat: true }, implementation: async ({ initVat }, params) => { - return await initVat(params.vatConfig, new Map(params.state)); + return await initVat( + params.vatConfig, + new Map(params.state), + params.allowedGlobalNames, + ); }, }; diff --git a/packages/ocap-kernel/src/vats/VatHandle.ts b/packages/ocap-kernel/src/vats/VatHandle.ts index ad004e38d9..4da7f6a8d9 100644 --- a/packages/ocap-kernel/src/vats/VatHandle.ts +++ b/packages/ocap-kernel/src/vats/VatHandle.ts @@ -45,6 +45,7 @@ type VatConstructorProps = { kernelStore: KernelStore; kernelQueue: KernelQueue; logger?: Logger | undefined; + allowedGlobalNames?: string[] | undefined; }; /** @@ -63,6 +64,9 @@ export class VatHandle implements EndpointHandle { /** Logger for outputting messages (such as errors) to the console */ readonly #logger: Logger | undefined; + /** Optional list of allowed global names for vat endowments */ + readonly #allowedGlobalNames: string[] | undefined; + /** Storage holding the kernel's persistent state */ readonly #kernelStore: KernelStore; @@ -89,6 +93,7 @@ export class VatHandle implements EndpointHandle { * @param params.kernelStore - The kernel's persistent state store. * @param params.kernelQueue - The kernel's queue. * @param params.logger - Optional logger for error and diagnostic output. + * @param params.allowedGlobalNames - Optional list of allowed global names for vat endowments. */ // eslint-disable-next-line no-restricted-syntax private constructor({ @@ -98,10 +103,12 @@ export class VatHandle implements EndpointHandle { kernelStore, kernelQueue, logger, + allowedGlobalNames, }: VatConstructorProps) { this.vatId = vatId; this.config = vatConfig; this.#logger = logger; + this.#allowedGlobalNames = allowedGlobalNames; this.#vatStream = vatStream; this.#kernelStore = kernelStore; this.#vatStore = kernelStore.makeVatStore(vatId); @@ -171,6 +178,9 @@ export class VatHandle implements EndpointHandle { params: { vatConfig: this.config, state: this.#vatStore.getKVData(), + ...(this.#allowedGlobalNames + ? { allowedGlobalNames: this.#allowedGlobalNames } + : {}), }, }); } diff --git a/packages/ocap-kernel/src/vats/VatManager.ts b/packages/ocap-kernel/src/vats/VatManager.ts index 1f1da7580a..d315c44dc3 100644 --- a/packages/ocap-kernel/src/vats/VatManager.ts +++ b/packages/ocap-kernel/src/vats/VatManager.ts @@ -25,6 +25,7 @@ type VatManagerOptions = { kernelStore: KernelStore; kernelQueue: KernelQueue; logger?: Logger; + allowedGlobalNames?: string[] | undefined; }; /** @@ -46,6 +47,9 @@ export class VatManager { /** Logger for outputting messages (such as errors) to the console */ readonly #logger: Logger; + /** Optional list of allowed global names for vat endowments */ + readonly #allowedGlobalNames: string[] | undefined; + /** * Creates a new VatManager instance. * @@ -54,18 +58,21 @@ export class VatManager { * @param options.kernelStore - The kernel's persistent state store. * @param options.kernelQueue - The kernel's message queue for scheduling deliveries. * @param options.logger - Logger instance for debugging and diagnostics. + * @param options.allowedGlobalNames - Optional list of allowed global names for vat endowments. */ constructor({ platformServices, kernelStore, kernelQueue, logger, + allowedGlobalNames, }: VatManagerOptions) { this.#vats = new Map(); this.#platformServices = platformServices; this.#kernelStore = kernelStore; this.#kernelQueue = kernelQueue; this.#logger = logger ?? new Logger('VatManager'); + this.#allowedGlobalNames = allowedGlobalNames; harden(this); } @@ -134,6 +141,7 @@ export class VatManager { kernelStore: this.#kernelStore, kernelQueue: this.#kernelQueue, logger: vatLogger, + allowedGlobalNames: this.#allowedGlobalNames, }); this.#vats.set(vatId, vat); } diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.test.ts b/packages/ocap-kernel/src/vats/VatSupervisor.test.ts index 2a327838c9..da181ea210 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.test.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.test.ts @@ -7,6 +7,7 @@ import { TestDuplexStream } from '@ocap/repo-tools/test-utils/streams'; import { describe, it, expect, vi } from 'vitest'; import { VatSupervisor } from './VatSupervisor.ts'; +import type { FetchBlob } from './VatSupervisor.ts'; vi.mock('./syscall.ts', () => ({ makeSupervisorSyscall: vi.fn(() => ({ @@ -28,12 +29,16 @@ const makeVatSupervisor = async ({ vatPowers, makePlatform, platformOptions, + allowedGlobals, + fetchBlob, }: { dispatch?: (input: unknown) => void | Promise; logger?: Logger; vatPowers?: Record; makePlatform?: PlatformFactory; platformOptions?: Record; + allowedGlobals?: Record; + fetchBlob?: FetchBlob; } = {}): Promise<{ supervisor: VatSupervisor; stream: TestDuplexStream; @@ -54,6 +59,8 @@ const makeVatSupervisor = async ({ vatPowers: vatPowers ?? {}, makePlatform: makePlatform ?? defaultMakePlatform, platformOptions: platformOptions ?? {}, + allowedGlobals, + fetchBlob, }), stream: kernelStream, }; @@ -163,4 +170,52 @@ describe('VatSupervisor', () => { expect(supervisor).toBeInstanceOf(VatSupervisor); }); }); + + describe('allowedGlobals configuration', () => { + it('accepts a custom allowedGlobals parameter', async () => { + const { supervisor } = await makeVatSupervisor({ + allowedGlobals: { CustomGlobal: 'custom-value' }, + }); + expect(supervisor).toBeInstanceOf(VatSupervisor); + }); + + it('throws when a vat requests an unknown global', async () => { + const dispatch = vi.fn(); + + const mockFetchBlob: FetchBlob = vi.fn().mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(''), + }); + + const { stream } = await makeVatSupervisor({ + dispatch, + allowedGlobals: { Date: globalThis.Date }, + fetchBlob: mockFetchBlob, + }); + + await stream.receiveInput({ + id: 'test-init', + method: 'initVat', + params: { + vatConfig: { + bundleSpec: 'test.bundle', + parameters: {}, + globals: ['Date', 'UnknownThing'], + }, + state: [], + }, + jsonrpc: '2.0', + }); + await delay(50); + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'test-init', + error: expect.objectContaining({ + message: expect.stringContaining('unknown global "UnknownThing"'), + }), + }), + ); + }); + }); }); diff --git a/packages/ocap-kernel/src/vats/VatSupervisor.ts b/packages/ocap-kernel/src/vats/VatSupervisor.ts index d701fb247a..1a82f228b3 100644 --- a/packages/ocap-kernel/src/vats/VatSupervisor.ts +++ b/packages/ocap-kernel/src/vats/VatSupervisor.ts @@ -28,6 +28,7 @@ import { } from '@metamask/utils'; import { loadBundle } from './bundle-loader.ts'; +import { DEFAULT_ALLOWED_GLOBALS } from './endowments.ts'; import { makeGCAndFinalize } from '../garbage-collection/gc-finalize.ts'; import { makeDummyMeterControl } from '../liveslots/meter-control.ts'; import { makeSupervisorSyscall } from '../liveslots/syscall.ts'; @@ -58,6 +59,7 @@ type SupervisorConstructorProps = { platformOptions?: Record; vatPowers?: Record | undefined; fetchBlob?: FetchBlob; + allowedGlobals?: Record; }; const marshal = makeMarshal(undefined, undefined, { @@ -104,6 +106,9 @@ export class VatSupervisor { /** Options to pass to the makePlatform function. */ readonly #platformOptions: Record; + /** The set of globals that vats are allowed to request. */ + readonly #allowedGlobals: Record; + /** * Construct a new VatSupervisor instance. * @@ -115,6 +120,7 @@ export class VatSupervisor { * @param params.fetchBlob - Function to fetch the user code bundle for this vat. * @param params.makePlatform - Function to create the platform for this vat. * @param params.platformOptions - Options to pass to the makePlatform function. + * @param params.allowedGlobals - Map of allowed globals. Defaults to {@link DEFAULT_ALLOWED_GLOBALS}. */ constructor({ id, @@ -126,6 +132,7 @@ export class VatSupervisor { }, platformOptions, fetchBlob, + allowedGlobals = DEFAULT_ALLOWED_GLOBALS, }: SupervisorConstructorProps) { this.id = id; this.#kernelStream = kernelStream; @@ -137,6 +144,7 @@ export class VatSupervisor { this.#fetchBlob = fetchBlob ?? defaultFetchBlob; this.#platformOptions = platformOptions ?? {}; this.#makePlatform = makePlatform; + this.#allowedGlobals = harden(allowedGlobals); this.#rpcClient = new RpcClient( vatSyscallMethodSpecs, @@ -255,12 +263,13 @@ export class VatSupervisor { * * @param vatConfig - Configuration object describing the vat to be intialized. * @param state - A Map representing the current persistent state of the vat. - * + * @param allowedGlobalNames - Optional list of allowed global names to restrict the available endowments. * @returns a promise for a checkpoint of the new vat. */ async #initVat( vatConfig: VatConfig, state: Map, + allowedGlobalNames: string[] | undefined, ): Promise { if (this.#loaded) { throw Error( @@ -298,21 +307,26 @@ export class VatSupervisor { const { bundleSpec, parameters, platformConfig, globals } = vatConfig; - // Map of allowed global names to their values - const allowedGlobals: Record = { - Date: globalThis.Date, - TextEncoder: globalThis.TextEncoder, - TextDecoder: globalThis.TextDecoder, - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - }; + // If the kernel specified a restricted set of allowed global names, + // filter the full allowlist down to only those names. + const effectiveAllowedGlobals = allowedGlobalNames + ? Object.fromEntries( + allowedGlobalNames + .filter((name) => hasProperty(this.#allowedGlobals, name)) + .map((name) => [name, this.#allowedGlobals[name]]), + ) + : this.#allowedGlobals; // Build additional endowments from globals list const requestedGlobals: Record = {}; if (globals) { for (const name of globals) { - if (hasProperty(allowedGlobals, name)) { - requestedGlobals[name] = allowedGlobals[name]; + if (hasProperty(effectiveAllowedGlobals, name)) { + requestedGlobals[name] = effectiveAllowedGlobals[name]; + } else { + throw new Error( + `Vat "${this.id}" requested unknown global "${name}"`, + ); } } } diff --git a/packages/ocap-kernel/src/vats/endowments.test.ts b/packages/ocap-kernel/src/vats/endowments.test.ts new file mode 100644 index 0000000000..55e7c394cc --- /dev/null +++ b/packages/ocap-kernel/src/vats/endowments.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; + +import { DEFAULT_ALLOWED_GLOBALS } from './endowments.ts'; + +describe('DEFAULT_ALLOWED_GLOBALS', () => { + it('contains the expected global names', () => { + expect(Object.keys(DEFAULT_ALLOWED_GLOBALS).sort()).toStrictEqual([ + 'AbortController', + 'AbortSignal', + 'Date', + 'TextDecoder', + 'TextEncoder', + 'URL', + 'URLSearchParams', + 'atob', + 'btoa', + 'clearTimeout', + 'setTimeout', + ]); + }); + + it('is frozen', () => { + expect(Object.isFrozen(DEFAULT_ALLOWED_GLOBALS)).toBe(true); + }); +}); diff --git a/packages/ocap-kernel/src/vats/endowments.ts b/packages/ocap-kernel/src/vats/endowments.ts new file mode 100644 index 0000000000..ade626a84c --- /dev/null +++ b/packages/ocap-kernel/src/vats/endowments.ts @@ -0,0 +1,31 @@ +/** + * The default set of host/Web API globals that vats may request as endowments. + * These are NOT ECMAScript intrinsics and are therefore absent from SES + * Compartments unless explicitly provided. + * + * JS intrinsics (e.g. `ArrayBuffer`, `BigInt`, `Intl`, typed arrays) are + * already available in every Compartment and do not need to be endowed. + * `Date` is an intrinsic too, but lockdown tames it (`Date.now()` returns + * `NaN`); passing it here restores the real implementation. + * + * Functions that require a specific `this` context are bound to `globalThis` + * before hardening. + */ +export const DEFAULT_ALLOWED_GLOBALS: Record = harden({ + // Timers (host API) + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + + // Date (intrinsic, but tamed by lockdown — endowing restores real Date.now) + Date: globalThis.Date, + + // Web APIs + TextEncoder: globalThis.TextEncoder, + TextDecoder: globalThis.TextDecoder, + URL: globalThis.URL, + URLSearchParams: globalThis.URLSearchParams, + atob: globalThis.atob.bind(globalThis), + btoa: globalThis.btoa.bind(globalThis), + AbortController: globalThis.AbortController, + AbortSignal: globalThis.AbortSignal, +});