Skip to content
196 changes: 196 additions & 0 deletions packages/kernel-test/src/endowment-globals.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
84 changes: 84 additions & 0 deletions packages/kernel-test/src/vats/endowment-globals.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions packages/ocap-kernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions packages/ocap-kernel/src/Kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -114,6 +115,7 @@ export class Kernel {
keySeed?: string | undefined;
mnemonic?: string | undefined;
ioChannelFactory?: IOChannelFactory;
allowedGlobalNames?: string[];
} = {},
) {
this.#platformServices = platformServices;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(
Expand All @@ -241,6 +245,7 @@ export class Kernel {
mnemonic?: string | undefined;
ioChannelFactory?: IOChannelFactory;
systemSubclusters?: SystemSubclusterConfig[];
allowedGlobalNames?: string[];
} = {},
): Promise<Kernel> {
const kernel = new Kernel(platformServices, kernelDatabase, options);
Expand Down
1 change: 1 addition & 0 deletions packages/ocap-kernel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('index', () => {
expect(Object.keys(indexModule).sort()).toStrictEqual([
'CapDataStruct',
'ClusterConfigStruct',
'DEFAULT_ALLOWED_GLOBALS',
'Kernel',
'KernelStatusStruct',
'SubclusterStruct',
Expand Down
1 change: 1 addition & 0 deletions packages/ocap-kernel/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
16 changes: 14 additions & 2 deletions packages/ocap-kernel/src/rpc/vat/initVat.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<typeof paramsStruct>;
Expand All @@ -28,6 +35,7 @@ export const initVatSpec: InitVatSpec = {
export type InitVat = (
vatConfig: VatConfig,
state: Map<string, string>,
allowedGlobalNames: string[] | undefined,
) => Promise<VatDeliveryResult>;

type InitVatHooks = {
Expand All @@ -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,
);
},
};
Loading
Loading