Skip to content

Commit 3af873b

Browse files
sirtimidclaude
andcommitted
feat(ocap-kernel): integrate Snaps attenuated endowment factories
Replaces raw vat-global endowments with attenuated factories from `@metamask/snaps-execution-environments/endowments`: - Adds `setInterval`, `clearInterval`, `crypto`, `SubtleCrypto`, and `Math` (with `crypto.getRandomValues`-sourced `Math.random`) to the default allowlist. - Replaces the raw `setTimeout`/`clearTimeout` and `Date` with the monotonic, attenuated Snaps factories. - Wires each factory's `teardownFunction` into `VatSupervisor.terminate` via an aggregate teardown built on `Promise.allSettled`, so pending timers and open resources are released even if one teardown rejects. - Replaces the `DEFAULT_ALLOWED_GLOBALS` constant + `allowedGlobals` constructor parameter with `createDefaultEndowments()` factory and `makeAllowedGlobals` parameter; the factory is invoked once per supervisor so state is isolated per vat. NOTE: this change depends on the `/endowments` subpath export landing in `@metamask/snaps-execution-environments` (MetaMask/snaps#3957). The PR stays in draft until that is released; the `^11.1.0` version pin is a placeholder and will be updated when the release ships. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ce9b92a commit 3af873b

11 files changed

Lines changed: 358 additions & 45 deletions

File tree

packages/kernel-test/src/endowment-globals.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ describe('global endowments', () => {
113113
expect(logs).toContain('timer: fired');
114114
});
115115

116+
it('can use setInterval and clearInterval', async () => {
117+
const { kernel, entries } = await setup({
118+
globals: ['setInterval', 'clearInterval'],
119+
});
120+
121+
await kernel.queueMessage(v1Root, 'testInterval', []);
122+
await waitUntilQuiescent();
123+
124+
const logs = extractTestLogs(entries, vatId);
125+
expect(logs).toContain('interval: ticks=2');
126+
});
127+
116128
it('can use real Date (not tamed)', async () => {
117129
const { kernel, entries } = await setup({ globals: ['Date'] });
118130

@@ -123,6 +135,28 @@ describe('global endowments', () => {
123135
expect(logs).toContain('date: isReal=true');
124136
});
125137

138+
it('can use crypto.getRandomValues', async () => {
139+
const { kernel, entries } = await setup({
140+
globals: ['crypto', 'SubtleCrypto'],
141+
});
142+
143+
await kernel.queueMessage(v1Root, 'testCrypto', []);
144+
await waitUntilQuiescent();
145+
146+
const logs = extractTestLogs(entries, vatId);
147+
expect(logs).toContain('crypto: hasRandomBytes=true');
148+
});
149+
150+
it('can use Math.random sourced from crypto.getRandomValues', async () => {
151+
const { kernel, entries } = await setup({ globals: ['Math'] });
152+
153+
await kernel.queueMessage(v1Root, 'testMath', []);
154+
await waitUntilQuiescent();
155+
156+
const logs = extractTestLogs(entries, vatId);
157+
expect(logs).toContain('math: inRange=true');
158+
});
159+
126160
describe('host APIs are absent when not endowed', () => {
127161
// These are Web/host APIs that are NOT JS intrinsics — they should
128162
// be genuinely absent from a SES compartment unless explicitly endowed.
@@ -137,6 +171,10 @@ describe('global endowments', () => {
137171
'AbortSignal',
138172
'setTimeout',
139173
'clearTimeout',
174+
'setInterval',
175+
'clearInterval',
176+
'crypto',
177+
'SubtleCrypto',
140178
])('does not have %s without endowing it', async (name) => {
141179
// Launch with no globals at all
142180
const { kernel, entries } = await setup({ globals: [] });
@@ -155,6 +193,14 @@ describe('global endowments', () => {
155193
'secure mode',
156194
);
157195
});
196+
197+
it('throws when calling tamed Math.random without endowing Math', async () => {
198+
const { kernel } = await setup({ globals: [] });
199+
200+
await expect(kernel.queueMessage(v1Root, 'testMath', [])).rejects.toThrow(
201+
'secure mode',
202+
);
203+
});
158204
});
159205

160206
describe('kernel-level allowedGlobalNames restriction', () => {

packages/kernel-test/src/vats/endowment-globals.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,45 @@ export function buildRootObject(vatPowers: TestPowers) {
6262
});
6363
},
6464

65+
testInterval: async () => {
66+
return new Promise((resolve) => {
67+
let tickCount = 0;
68+
const handle = setInterval(() => {
69+
tickCount += 1;
70+
if (tickCount >= 2) {
71+
clearInterval(handle);
72+
tlog(`interval: ticks=${tickCount}`);
73+
resolve(tickCount);
74+
}
75+
}, 10);
76+
});
77+
},
78+
6579
testDate: () => {
6680
const now = Date.now();
6781
const isReal = !Number.isNaN(now) && now > 0;
6882
tlog(`date: isReal=${String(isReal)}`);
6983
return isReal;
7084
},
7185

86+
testCrypto: () => {
87+
// A 16-byte buffer makes the all-zero coincidence vanishingly small
88+
// (2^-128); a 4-byte buffer has a ~1-in-4-billion false-negative rate.
89+
const buffer = new Uint8Array(16);
90+
// eslint-disable-next-line n/no-unsupported-features/node-builtins -- `crypto` here is the vat endowment, not the Node global; the rule's engines check does not apply inside a SES Compartment.
91+
crypto.getRandomValues(buffer);
92+
const hasRandomBytes = buffer.some((byte) => byte !== 0);
93+
tlog(`crypto: hasRandomBytes=${String(hasRandomBytes)}`);
94+
return hasRandomBytes;
95+
},
96+
97+
testMath: () => {
98+
const value = Math.random();
99+
const inRange = value >= 0 && value < 1;
100+
tlog(`math: inRange=${String(inRange)}`);
101+
return inRange;
102+
},
103+
72104
checkGlobal: (name: string) => {
73105
// In a SES compartment, globalThis points to the compartment's own
74106
// global object, so this correctly detects whether an endowment was

packages/ocap-kernel/CHANGELOG.md

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

1010
### Added
1111

12+
- Integrate Snaps attenuated endowment factories into vat globals ([#935](https://github.com/MetaMask/ocap-kernel/pull/935))
13+
- Add `setInterval`, `clearInterval`, `crypto`, `SubtleCrypto`, and `Math` (crypto-backed `Math.random`) to the default vat endowments
14+
- Swap raw `setTimeout`/`clearTimeout` and `Date` for attenuated versions from `@metamask/snaps-execution-environments`
15+
- Wire factory `teardownFunction`s into `VatSupervisor.terminate()` so pending timers and other resources are released on vat termination
16+
- Replace exported `DEFAULT_ALLOWED_GLOBALS` constant with `createDefaultEndowments()` factory and `VatEndowments` type; `VatSupervisor` now accepts `makeAllowedGlobals` in place of `allowedGlobals`
1217
- Make vat global allowlist configurable and expand available endowments ([#933](https://github.com/MetaMask/ocap-kernel/pull/933))
1318
- Export `DEFAULT_ALLOWED_GLOBALS` with `URL`, `URLSearchParams`, `atob`, `btoa`, `AbortController`, and `AbortSignal` in addition to the existing globals
1419
- Accept optional `allowedGlobals` on `VatSupervisor` for custom allowlists

packages/ocap-kernel/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"@metamask/kernel-utils": "workspace:^",
9595
"@metamask/logger": "workspace:^",
9696
"@metamask/rpc-errors": "^7.0.3",
97+
"@metamask/snaps-execution-environments": "^11.1.0",
9798
"@metamask/streams": "workspace:^",
9899
"@metamask/superstruct": "^3.2.1",
99100
"@metamask/utils": "^11.9.0",

packages/ocap-kernel/src/Kernel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export class Kernel {
232232
* @param options.mnemonic - Optional BIP39 mnemonic for deriving the kernel identity.
233233
* @param options.ioChannelFactory - Optional factory for creating IO channels.
234234
* @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.
235+
* @param options.allowedGlobalNames - Optional list of allowed global names for vat endowments. When set, only these names from the `VatSupervisor`'s configured endowments (see `createDefaultEndowments`) are available to vats.
236236
* @returns A promise for the new kernel instance.
237237
*/
238238
static async make(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ describe('index', () => {
77
expect(Object.keys(indexModule).sort()).toStrictEqual([
88
'CapDataStruct',
99
'ClusterConfigStruct',
10-
'DEFAULT_ALLOWED_GLOBALS',
1110
'Kernel',
1211
'KernelStatusStruct',
1312
'SubclusterStruct',
1413
'VatConfigStruct',
1514
'VatHandle',
1615
'VatIdStruct',
1716
'VatSupervisor',
17+
'createDefaultEndowments',
1818
'generateMnemonic',
1919
'initTransport',
2020
'insistKRef',

packages/ocap-kernel/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
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';
4+
export { createDefaultEndowments } from './vats/endowments.ts';
5+
export type { VatEndowments } from './vats/endowments.ts';
56
export { initTransport } from './remotes/platform/transport.ts';
67
export type { IOChannel, IOChannelFactory } from './io/types.ts';
78
export type {

packages/ocap-kernel/src/vats/VatSupervisor.test.ts

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { rpcErrors } from '@metamask/rpc-errors';
66
import { TestDuplexStream } from '@ocap/repo-tools/test-utils/streams';
77
import { describe, it, expect, vi } from 'vitest';
88

9+
import type { VatEndowments } from './endowments.ts';
910
import { VatSupervisor } from './VatSupervisor.ts';
1011
import type { FetchBlob } from './VatSupervisor.ts';
1112

@@ -23,21 +24,29 @@ vi.mock('@agoric/swingset-liveslots', () => ({
2324
})),
2425
}));
2526

27+
const makeVatEndowments = (
28+
globals: Record<string, unknown>,
29+
teardown: () => Promise<void> = async () => undefined,
30+
): VatEndowments => ({
31+
globals: harden({ ...globals }),
32+
teardown,
33+
});
34+
2635
const makeVatSupervisor = async ({
2736
dispatch,
2837
logger,
2938
vatPowers,
3039
makePlatform,
3140
platformOptions,
32-
allowedGlobals,
41+
makeAllowedGlobals,
3342
fetchBlob,
3443
}: {
3544
dispatch?: (input: unknown) => void | Promise<void>;
3645
logger?: Logger;
3746
vatPowers?: Record<string, unknown>;
3847
makePlatform?: PlatformFactory;
3948
platformOptions?: Record<string, unknown>;
40-
allowedGlobals?: Record<string, unknown>;
49+
makeAllowedGlobals?: () => VatEndowments;
4150
fetchBlob?: FetchBlob;
4251
} = {}): Promise<{
4352
supervisor: VatSupervisor;
@@ -59,7 +68,7 @@ const makeVatSupervisor = async ({
5968
vatPowers: vatPowers ?? {},
6069
makePlatform: makePlatform ?? defaultMakePlatform,
6170
platformOptions: platformOptions ?? {},
62-
allowedGlobals,
71+
makeAllowedGlobals,
6372
fetchBlob,
6473
}),
6574
stream: kernelStream,
@@ -143,6 +152,59 @@ describe('VatSupervisor', () => {
143152
value: undefined,
144153
});
145154
});
155+
156+
it('calls the endowments teardown before closing the stream', async () => {
157+
const teardown = vi.fn().mockResolvedValue(undefined);
158+
const { supervisor, stream } = await makeVatSupervisor({
159+
makeAllowedGlobals: () => makeVatEndowments({}, teardown),
160+
});
161+
const endSpy = vi.spyOn(stream, 'end');
162+
163+
await supervisor.terminate();
164+
165+
expect(teardown).toHaveBeenCalledTimes(1);
166+
expect(endSpy).toHaveBeenCalledTimes(1);
167+
expect(teardown.mock.invocationCallOrder[0]).toBeLessThan(
168+
endSpy.mock.invocationCallOrder[0] as number,
169+
);
170+
});
171+
172+
it('closes the stream and logs when teardown rejects', async () => {
173+
const logger = {
174+
error: vi.fn(),
175+
subLogger: vi.fn(() => logger),
176+
} as unknown as Logger;
177+
const teardownError = new Error('boom');
178+
const { supervisor, stream } = await makeVatSupervisor({
179+
logger,
180+
makeAllowedGlobals: () =>
181+
makeVatEndowments({}, async () => {
182+
throw teardownError;
183+
}),
184+
});
185+
186+
await supervisor.terminate();
187+
188+
expect(logger.error).toHaveBeenCalledWith(
189+
expect.stringContaining('Endowment teardown failed'),
190+
teardownError,
191+
);
192+
expect(await stream.next()).toStrictEqual({
193+
done: true,
194+
value: undefined,
195+
});
196+
});
197+
198+
it('is safe to call twice', async () => {
199+
const teardown = vi.fn().mockResolvedValue(undefined);
200+
const { supervisor } = await makeVatSupervisor({
201+
makeAllowedGlobals: () => makeVatEndowments({}, teardown),
202+
});
203+
204+
await supervisor.terminate();
205+
expect(await supervisor.terminate()).toBeUndefined();
206+
expect(teardown).toHaveBeenCalledTimes(2);
207+
});
146208
});
147209

148210
describe('platform configuration', () => {
@@ -171,12 +233,16 @@ describe('VatSupervisor', () => {
171233
});
172234
});
173235

174-
describe('allowedGlobals configuration', () => {
175-
it('accepts a custom allowedGlobals parameter', async () => {
236+
describe('makeAllowedGlobals configuration', () => {
237+
it('invokes the factory exactly once at construction', async () => {
238+
const factory = vi.fn(() =>
239+
makeVatEndowments({ CustomGlobal: 'custom-value' }),
240+
);
176241
const { supervisor } = await makeVatSupervisor({
177-
allowedGlobals: { CustomGlobal: 'custom-value' },
242+
makeAllowedGlobals: factory,
178243
});
179244
expect(supervisor).toBeInstanceOf(VatSupervisor);
245+
expect(factory).toHaveBeenCalledTimes(1);
180246
});
181247

182248
it('throws when a vat requests an unknown global', async () => {
@@ -189,7 +255,7 @@ describe('VatSupervisor', () => {
189255

190256
const { stream } = await makeVatSupervisor({
191257
dispatch,
192-
allowedGlobals: { Date: globalThis.Date },
258+
makeAllowedGlobals: () => makeVatEndowments({ Date: globalThis.Date }),
193259
fetchBlob: mockFetchBlob,
194260
});
195261

packages/ocap-kernel/src/vats/VatSupervisor.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import {
2828
} from '@metamask/utils';
2929

3030
import { loadBundle } from './bundle-loader.ts';
31-
import { DEFAULT_ALLOWED_GLOBALS } from './endowments.ts';
31+
import { createDefaultEndowments } from './endowments.ts';
32+
import type { VatEndowments } from './endowments.ts';
3233
import { makeGCAndFinalize } from '../garbage-collection/gc-finalize.ts';
3334
import { makeDummyMeterControl } from '../liveslots/meter-control.ts';
3435
import { makeSupervisorSyscall } from '../liveslots/syscall.ts';
@@ -59,7 +60,7 @@ type SupervisorConstructorProps = {
5960
platformOptions?: Record<string, unknown>;
6061
vatPowers?: Record<string, unknown> | undefined;
6162
fetchBlob?: FetchBlob;
62-
allowedGlobals?: Record<string, unknown>;
63+
makeAllowedGlobals?: () => VatEndowments;
6364
};
6465

6566
const marshal = makeMarshal(undefined, undefined, {
@@ -109,6 +110,13 @@ export class VatSupervisor {
109110
/** The set of globals that vats are allowed to request. */
110111
readonly #allowedGlobals: Record<string, unknown>;
111112

113+
/**
114+
* Releases resources held by the endowment factories (e.g. pending timers,
115+
* open network connections). Invoked from {@link terminate} before closing
116+
* the kernel stream; failures are logged but do not prevent stream closure.
117+
*/
118+
readonly #endowmentsTeardown: () => Promise<void>;
119+
112120
/**
113121
* Construct a new VatSupervisor instance.
114122
*
@@ -120,7 +128,9 @@ export class VatSupervisor {
120128
* @param params.fetchBlob - Function to fetch the user code bundle for this vat.
121129
* @param params.makePlatform - Function to create the platform for this vat.
122130
* @param params.platformOptions - Options to pass to the makePlatform function.
123-
* @param params.allowedGlobals - Map of allowed globals. Defaults to {@link DEFAULT_ALLOWED_GLOBALS}.
131+
* @param params.makeAllowedGlobals - Factory invoked exactly once at
132+
* construction to produce this vat's isolated endowments and an aggregate
133+
* teardown function. Defaults to {@link createDefaultEndowments}.
124134
*/
125135
constructor({
126136
id,
@@ -132,7 +142,7 @@ export class VatSupervisor {
132142
},
133143
platformOptions,
134144
fetchBlob,
135-
allowedGlobals = DEFAULT_ALLOWED_GLOBALS,
145+
makeAllowedGlobals = createDefaultEndowments,
136146
}: SupervisorConstructorProps) {
137147
this.id = id;
138148
this.#kernelStream = kernelStream;
@@ -144,7 +154,9 @@ export class VatSupervisor {
144154
this.#fetchBlob = fetchBlob ?? defaultFetchBlob;
145155
this.#platformOptions = platformOptions ?? {};
146156
this.#makePlatform = makePlatform;
147-
this.#allowedGlobals = harden(allowedGlobals);
157+
const { globals, teardown } = makeAllowedGlobals();
158+
this.#allowedGlobals = globals;
159+
this.#endowmentsTeardown = teardown;
148160

149161
this.#rpcClient = new RpcClient(
150162
vatSyscallMethodSpecs,
@@ -174,9 +186,22 @@ export class VatSupervisor {
174186
/**
175187
* Terminate the VatSupervisor.
176188
*
189+
* Endowment teardown runs first so pending timers and other resources are
190+
* released before the kernel stream closes. Teardown failures are logged
191+
* but never block stream closure — the original `error` (if any) must
192+
* always reach the kernel.
193+
*
177194
* @param error - The error to terminate the VatSupervisor with.
178195
*/
179196
async terminate(error?: Error): Promise<void> {
197+
try {
198+
await this.#endowmentsTeardown();
199+
} catch (teardownError) {
200+
this.#logger.error(
201+
`Endowment teardown failed during terminate of vat "${this.id}"`,
202+
teardownError,
203+
);
204+
}
180205
await this.#kernelStream.end(error);
181206
}
182207

0 commit comments

Comments
 (0)