Skip to content

Commit b17f742

Browse files
sirtimidclaude
andauthored
feat(ocap-kernel): configurable vat global allowlist (#933)
## Summary This is **Part 1** of the vat endowments overhaul (closes #813). Part 2 will integrate attenuated endowment factories from `@metamask/snaps-execution-environments` once [MetaMask/snaps#3957](MetaMask/snaps#3957) is merged and released — adding timer teardown on vat termination, anti-timing-attack `Date`, and crypto-backed `Math.random`. The hardcoded `allowedGlobals` in `VatSupervisor` is extracted into a dedicated `endowments.ts` module and made configurable: - **New `DEFAULT_ALLOWED_GLOBALS` constant** — a hardened record of host/Web API endowments that SES Compartments do not provide by default. JS intrinsics (`ArrayBuffer`, `BigInt`, typed arrays, `Intl`, etc.) are excluded since they are already available in every Compartment. - **Expanded endowment set** — adds `URL`, `URLSearchParams`, `atob`, `btoa`, `AbortController`, `AbortSignal` alongside the existing `TextEncoder`, `TextDecoder`, `setTimeout`, `clearTimeout`, `Date`. - **Configurable `allowedGlobals` on `VatSupervisor`** — optional constructor parameter defaulting to `DEFAULT_ALLOWED_GLOBALS`. Custom maps are hardened on assignment. - **Warning on unknown globals** — when a vat requests a global not in the allowlist, a warning is logged instead of silently ignoring it. ## Testing Unit tests in `endowments.test.ts` verify the constant's shape and frozen state. `VatSupervisor.test.ts` tests the configurable parameter, the warning behavior (both positive and negative paths via `initVat` RPC). E2e tests in `kernel-test` exercise each endowment inside a real SES Compartment and verify that all host APIs are genuinely absent when not endowed, including that the tamed `Date.now` throws in secure mode without the `Date` endowment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes vat endowment/SES-global handling by expanding and centralizing the allowlist and adding kernel-controlled restrictions, which can affect vat initialization and security boundaries if misconfigured. > > **Overview** > Adds a hardened `DEFAULT_ALLOWED_GLOBALS` export and expands the default endowment set (e.g. `URL`, `URLSearchParams`, `atob`/`btoa`, `AbortController`/`AbortSignal`) used to explicitly provide host/Web globals to vats. > > Introduces a kernel-level `allowedGlobalNames` option that is propagated through `VatManager`/`VatHandle` to the `initVat` RPC and enforced in `VatSupervisor` by filtering the allowlist; vats now fail initialization when requesting a global outside the effective allowlist. > > Adds unit + integration tests (including a new kernel-test vat) to verify each endowment works when granted, is absent when not endowed, and that kernel restrictions reject disallowed globals; updates public exports and changelog accordingly. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a105504. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8ad4048 commit b17f742

13 files changed

Lines changed: 462 additions & 13 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { NodejsPlatformServices } from '@metamask/kernel-node-runtime';
2+
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
3+
import { waitUntilQuiescent } from '@metamask/kernel-utils';
4+
import {
5+
Logger,
6+
makeConsoleTransport,
7+
makeArrayTransport,
8+
} from '@metamask/logger';
9+
import type { LogEntry } from '@metamask/logger';
10+
import { Kernel } from '@metamask/ocap-kernel';
11+
import type { KRef, VatId } from '@metamask/ocap-kernel';
12+
import { getWorkerFile } from '@ocap/nodejs-test-workers';
13+
import { describe, expect, it } from 'vitest';
14+
15+
import { extractTestLogs, getBundleSpec } from './utils.ts';
16+
17+
describe('global endowments', () => {
18+
const vatId: VatId = 'v1';
19+
const v1Root: KRef = 'ko4';
20+
21+
const setup = async ({
22+
globals,
23+
allowedGlobalNames,
24+
}: {
25+
globals: string[];
26+
allowedGlobalNames?: string[];
27+
}) => {
28+
const entries: LogEntry[] = [];
29+
const logger = new Logger({
30+
transports: [makeConsoleTransport(), makeArrayTransport(entries)],
31+
});
32+
const database = await makeSQLKernelDatabase({});
33+
const platformServices = new NodejsPlatformServices({
34+
logger: logger.subLogger({ tags: ['vat-worker-manager'] }),
35+
workerFilePath: getWorkerFile('mock-fetch'),
36+
});
37+
const kernel = await Kernel.make(platformServices, database, {
38+
resetStorage: true,
39+
logger,
40+
allowedGlobalNames,
41+
});
42+
43+
await kernel.launchSubcluster({
44+
bootstrap: 'main',
45+
vats: {
46+
main: {
47+
bundleSpec: getBundleSpec('endowment-globals'),
48+
parameters: {},
49+
globals,
50+
},
51+
},
52+
});
53+
await waitUntilQuiescent();
54+
55+
return { kernel, entries };
56+
};
57+
58+
it('can use TextEncoder and TextDecoder', async () => {
59+
const { kernel, entries } = await setup({
60+
globals: ['TextEncoder', 'TextDecoder'],
61+
});
62+
63+
await kernel.queueMessage(v1Root, 'testTextCodec', []);
64+
await waitUntilQuiescent();
65+
66+
const logs = extractTestLogs(entries, vatId);
67+
expect(logs).toContain('textCodec: hello');
68+
});
69+
70+
it('can use URL and URLSearchParams', async () => {
71+
const { kernel, entries } = await setup({
72+
globals: ['URL', 'URLSearchParams'],
73+
});
74+
75+
await kernel.queueMessage(v1Root, 'testUrl', []);
76+
await waitUntilQuiescent();
77+
78+
const logs = extractTestLogs(entries, vatId);
79+
expect(logs).toContain('url: /path params: 10');
80+
});
81+
82+
it('can use atob and btoa', async () => {
83+
const { kernel, entries } = await setup({ globals: ['atob', 'btoa'] });
84+
85+
await kernel.queueMessage(v1Root, 'testBase64', []);
86+
await waitUntilQuiescent();
87+
88+
const logs = extractTestLogs(entries, vatId);
89+
expect(logs).toContain('base64: hello world');
90+
});
91+
92+
it('can use AbortController and AbortSignal', async () => {
93+
const { kernel, entries } = await setup({
94+
globals: ['AbortController', 'AbortSignal'],
95+
});
96+
97+
await kernel.queueMessage(v1Root, 'testAbort', []);
98+
await waitUntilQuiescent();
99+
100+
const logs = extractTestLogs(entries, vatId);
101+
expect(logs).toContain('abort: before=false after=true');
102+
});
103+
104+
it('can use setTimeout and clearTimeout', async () => {
105+
const { kernel, entries } = await setup({
106+
globals: ['setTimeout', 'clearTimeout'],
107+
});
108+
109+
await kernel.queueMessage(v1Root, 'testTimers', []);
110+
await waitUntilQuiescent();
111+
112+
const logs = extractTestLogs(entries, vatId);
113+
expect(logs).toContain('timer: fired');
114+
});
115+
116+
it('can use real Date (not tamed)', async () => {
117+
const { kernel, entries } = await setup({ globals: ['Date'] });
118+
119+
await kernel.queueMessage(v1Root, 'testDate', []);
120+
await waitUntilQuiescent();
121+
122+
const logs = extractTestLogs(entries, vatId);
123+
expect(logs).toContain('date: isReal=true');
124+
});
125+
126+
describe('host APIs are absent when not endowed', () => {
127+
// These are Web/host APIs that are NOT JS intrinsics — they should
128+
// be genuinely absent from a SES compartment unless explicitly endowed.
129+
it.each([
130+
'TextEncoder',
131+
'TextDecoder',
132+
'URL',
133+
'URLSearchParams',
134+
'atob',
135+
'btoa',
136+
'AbortController',
137+
'AbortSignal',
138+
'setTimeout',
139+
'clearTimeout',
140+
])('does not have %s without endowing it', async (name) => {
141+
// Launch with no globals at all
142+
const { kernel, entries } = await setup({ globals: [] });
143+
144+
await kernel.queueMessage(v1Root, 'checkGlobal', [name]);
145+
await waitUntilQuiescent();
146+
147+
const logs = extractTestLogs(entries, vatId);
148+
expect(logs).toContain(`checkGlobal: ${name}=false`);
149+
});
150+
151+
it('throws when calling tamed Date.now without endowing Date', async () => {
152+
const { kernel } = await setup({ globals: [] });
153+
154+
await expect(kernel.queueMessage(v1Root, 'testDate', [])).rejects.toThrow(
155+
'secure mode',
156+
);
157+
});
158+
});
159+
160+
describe('kernel-level allowedGlobalNames restriction', () => {
161+
it('throws when a vat requests a global excluded by the kernel', async () => {
162+
// Kernel only allows TextEncoder/TextDecoder — vat also requests URL
163+
await expect(
164+
setup({
165+
globals: ['TextEncoder', 'TextDecoder', 'URL'],
166+
allowedGlobalNames: ['TextEncoder', 'TextDecoder'],
167+
}),
168+
).rejects.toThrow('unknown global "URL"');
169+
});
170+
171+
it('initializes when all vat globals are within allowedGlobalNames', async () => {
172+
const { kernel, entries } = await setup({
173+
globals: ['TextEncoder', 'TextDecoder'],
174+
allowedGlobalNames: ['TextEncoder', 'TextDecoder'],
175+
});
176+
177+
await kernel.queueMessage(v1Root, 'testTextCodec', []);
178+
await waitUntilQuiescent();
179+
180+
const logs = extractTestLogs(entries, vatId);
181+
expect(logs).toContain('textCodec: hello');
182+
});
183+
184+
it('allows all globals when allowedGlobalNames is omitted', async () => {
185+
const { kernel, entries } = await setup({
186+
globals: ['URL', 'URLSearchParams'],
187+
});
188+
189+
await kernel.queueMessage(v1Root, 'testUrl', []);
190+
await waitUntilQuiescent();
191+
192+
const logs = extractTestLogs(entries, vatId);
193+
expect(logs).toContain('url: /path params: 10');
194+
});
195+
});
196+
});
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { makeDefaultExo } from '@metamask/kernel-utils/exo';
2+
3+
import { unwrapTestLogger } from '../test-powers.ts';
4+
import type { TestPowers } from '../test-powers.ts';
5+
6+
/**
7+
* Build a root object for a vat that exercises global endowments.
8+
*
9+
* @param vatPowers - The powers of the vat.
10+
* @returns The root object.
11+
*/
12+
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
13+
export function buildRootObject(vatPowers: TestPowers) {
14+
const tlog = unwrapTestLogger(vatPowers, 'endowment-globals');
15+
16+
tlog('buildRootObject');
17+
18+
const root = makeDefaultExo('root', {
19+
bootstrap: () => {
20+
tlog('bootstrap');
21+
},
22+
23+
testTextCodec: () => {
24+
const encoder = new TextEncoder();
25+
const encoded = encoder.encode('hello');
26+
const decoder = new TextDecoder();
27+
const decoded = decoder.decode(encoded);
28+
tlog(`textCodec: ${decoded}`);
29+
return decoded;
30+
},
31+
32+
testUrl: () => {
33+
const url = new URL('https://example.com/path?a=1');
34+
url.searchParams.set('b', '2');
35+
const params = new URLSearchParams('x=10&y=20');
36+
tlog(`url: ${url.pathname} params: ${params.get('x')}`);
37+
return url.toString();
38+
},
39+
40+
testBase64: () => {
41+
const encoded = btoa('hello world');
42+
const decoded = atob(encoded);
43+
tlog(`base64: ${decoded}`);
44+
return decoded;
45+
},
46+
47+
testAbort: () => {
48+
const controller = new AbortController();
49+
const { signal } = controller;
50+
const { aborted } = signal;
51+
controller.abort('test reason');
52+
tlog(`abort: before=${String(aborted)} after=${String(signal.aborted)}`);
53+
return signal.aborted;
54+
},
55+
56+
testTimers: async () => {
57+
return new Promise((resolve) => {
58+
setTimeout(() => {
59+
tlog('timer: fired');
60+
resolve('fired');
61+
}, 10);
62+
});
63+
},
64+
65+
testDate: () => {
66+
const now = Date.now();
67+
const isReal = !Number.isNaN(now) && now > 0;
68+
tlog(`date: isReal=${String(isReal)}`);
69+
return isReal;
70+
},
71+
72+
checkGlobal: (name: string) => {
73+
// In a SES compartment, globalThis points to the compartment's own
74+
// global object, so this correctly detects whether an endowment was
75+
// provided. Intrinsics (e.g. ArrayBuffer) are always present;
76+
// host/Web APIs (e.g. TextEncoder) are only present if endowed.
77+
const exists = name in globalThis;
78+
tlog(`checkGlobal: ${name}=${String(exists)}`);
79+
return exists;
80+
},
81+
});
82+
83+
return root;
84+
}

packages/ocap-kernel/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Make vat global allowlist configurable and expand available endowments ([#933](https://github.com/MetaMask/ocap-kernel/pull/933))
13+
- Export `DEFAULT_ALLOWED_GLOBALS` with `URL`, `URLSearchParams`, `atob`, `btoa`, `AbortController`, and `AbortSignal` in addition to the existing globals
14+
- Accept optional `allowedGlobals` on `VatSupervisor` for custom allowlists
15+
- Log a warning when a vat requests an unknown global
16+
1017
### Changed
1118

1219
- 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))

packages/ocap-kernel/src/Kernel.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export class Kernel {
103103
* @param options.keySeed - Optional seed for libp2p key generation.
104104
* @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity.
105105
* @param options.ioChannelFactory - Optional factory for creating IO channels.
106+
* @param options.allowedGlobalNames - Optional list of allowed global names for vat endowments.
106107
*/
107108
// eslint-disable-next-line no-restricted-syntax
108109
private constructor(
@@ -114,6 +115,7 @@ export class Kernel {
114115
keySeed?: string | undefined;
115116
mnemonic?: string | undefined;
116117
ioChannelFactory?: IOChannelFactory;
118+
allowedGlobalNames?: string[];
117119
} = {},
118120
) {
119121
this.#platformServices = platformServices;
@@ -145,6 +147,7 @@ export class Kernel {
145147
kernelStore: this.#kernelStore,
146148
kernelQueue: this.#kernelQueue,
147149
logger: this.#logger.subLogger({ tags: ['VatManager'] }),
150+
allowedGlobalNames: options.allowedGlobalNames,
148151
});
149152

150153
this.#remoteManager = new RemoteManager({
@@ -229,6 +232,7 @@ export class Kernel {
229232
* @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity.
230233
* @param options.ioChannelFactory - Optional factory for creating IO channels.
231234
* @param options.systemSubclusters - Optional array of system subcluster configurations.
235+
* @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.
232236
* @returns A promise for the new kernel instance.
233237
*/
234238
static async make(
@@ -241,6 +245,7 @@ export class Kernel {
241245
mnemonic?: string | undefined;
242246
ioChannelFactory?: IOChannelFactory;
243247
systemSubclusters?: SystemSubclusterConfig[];
248+
allowedGlobalNames?: string[];
244249
} = {},
245250
): Promise<Kernel> {
246251
const kernel = new Kernel(platformServices, kernelDatabase, options);

packages/ocap-kernel/src/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ describe('index', () => {
77
expect(Object.keys(indexModule).sort()).toStrictEqual([
88
'CapDataStruct',
99
'ClusterConfigStruct',
10+
'DEFAULT_ALLOWED_GLOBALS',
1011
'Kernel',
1112
'KernelStatusStruct',
1213
'SubclusterStruct',

packages/ocap-kernel/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { Kernel } from './Kernel.ts';
22
export { VatHandle } from './vats/VatHandle.ts';
33
export { VatSupervisor } from './vats/VatSupervisor.ts';
4+
export { DEFAULT_ALLOWED_GLOBALS } from './vats/endowments.ts';
45
export { initTransport } from './remotes/platform/transport.ts';
56
export type { IOChannel, IOChannelFactory } from './io/types.ts';
67
export type {

packages/ocap-kernel/src/rpc/vat/initVat.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods';
2-
import { array, object, string, tuple } from '@metamask/superstruct';
2+
import {
3+
array,
4+
exactOptional,
5+
object,
6+
string,
7+
tuple,
8+
} from '@metamask/superstruct';
39
import type { Infer } from '@metamask/superstruct';
410

511
import { VatDeliveryResultStruct } from './shared.ts';
@@ -9,6 +15,7 @@ import type { VatConfig, VatDeliveryResult } from '../../types.ts';
915
const paramsStruct = object({
1016
vatConfig: VatConfigStruct,
1117
state: array(tuple([string(), string()])),
18+
allowedGlobalNames: exactOptional(array(string())),
1219
});
1320

1421
type Params = Infer<typeof paramsStruct>;
@@ -28,6 +35,7 @@ export const initVatSpec: InitVatSpec = {
2835
export type InitVat = (
2936
vatConfig: VatConfig,
3037
state: Map<string, string>,
38+
allowedGlobalNames: string[] | undefined,
3139
) => Promise<VatDeliveryResult>;
3240

3341
type InitVatHooks = {
@@ -45,6 +53,10 @@ export const initVatHandler: InitVatHandler = {
4553
...initVatSpec,
4654
hooks: { initVat: true },
4755
implementation: async ({ initVat }, params) => {
48-
return await initVat(params.vatConfig, new Map(params.state));
56+
return await initVat(
57+
params.vatConfig,
58+
new Map(params.state),
59+
params.allowedGlobalNames,
60+
);
4961
},
5062
};

0 commit comments

Comments
 (0)