From 2285b4dcb56916296f104dd54a841419a59c5b82 Mon Sep 17 00:00:00 2001 From: ci-belphegor <324126930+ci-belphegor@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:33:05 -0400 Subject: [PATCH] feat(kernel-utils): narrow Wrap the derived interface guard in an exo whose methods forward to the base through E(). The base's guard is read over E() too, so one code path serves a local base and a base that later becomes a cross-vat presence. Enforcement is the returned exo's own guard checking; there is no separate check step. A module-level WeakMap records what each narrowing was minted from. It caches the base's guard, so narrowing a narrowing needs no read, and it flattens: the result forwards straight to the original base under both deltas conjoined, keeping a chain of any depth one hop deep. conjoinDeltas takes its keys from the incoming delta alone and refuses one the existing delta dropped, so flattening cannot reinstate authority an intermediate narrowing gave up. A forward for a method the base lacks rejects rather than yielding undefined, so a caller can tell absent authority from a call that returned nothing. narrow has no unit tests in this package: E reads globalThis.HandledPromise when it loads, and these tests run under mock-endoify, which sets that to plain Promise. The three narrowing cases in @ocap/kernel-test cover it end to end in a real vat instead. Co-Authored-By: Claude Opus 5 --- packages/kernel-test/src/narrowing.test.ts | 9 +- packages/kernel-utils/CHANGELOG.md | 3 +- .../src/narrow-interface-guard.test.ts | 48 +++++++- .../src/narrow-interface-guard.ts | 38 ++++++ packages/kernel-utils/src/narrowing.test.ts | 13 +-- packages/kernel-utils/src/narrowing.ts | 110 ++++++++++++++++-- 6 files changed, 196 insertions(+), 25 deletions(-) diff --git a/packages/kernel-test/src/narrowing.test.ts b/packages/kernel-test/src/narrowing.test.ts index 369d93bf2..2002e01e4 100644 --- a/packages/kernel-test/src/narrowing.test.ts +++ b/packages/kernel-test/src/narrowing.test.ts @@ -99,24 +99,21 @@ const makeTempTree = async (): Promise<{ }; describe('narrowing', () => { - // Unmarks at PR-6. - it.fails('narrows a vat-local exo', async () => { + it('narrows a vat-local exo', async () => { const kernel = await launchNarrowingVat(); expect( await probe(kernel, 'probeNarrowed', ['read', ['srv', 'data', 'x']]), ).toBe('ok:read:srv/data/x'); }); - // Unmarks at PR-6. - it.fails('rejects a call outside the narrowing', async () => { + it('rejects a call outside the narrowing', async () => { const kernel = await launchNarrowingVat(); expect( await probe(kernel, 'probeNarrowed', ['read', ['etc', 'passwd']]), ).toMatch(/^rejected:.*\bread\b/u); }); - // Unmarks at PR-6. - it.fails('drops methods absent from the delta', async () => { + it('drops methods absent from the delta', async () => { const kernel = await launchNarrowingVat(); expect( await probe(kernel, 'probeNarrowed', ['stat', ['srv', 'data', 'x']]), diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 3adb1d08c..926b76926 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `narrowInterfaceGuard` and the `NarrowingDelta` type, which derive the interface guard of a narrowing: each delta pattern is conjoined onto the base's guard at the argument position it addresses, arity and return guards are inherited verbatim, methods the delta does not name are dropped, and every method guard is asyncified for forwarding ([#1051](https://github.com/MetaMask/ocap-kernel/pull/1051)) - Add `pathUnder(segments)`, which builds an `@endo/patterns` pattern matching segment arrays under a prefix, with `..` excluded past the prefix so that traversal out of it is unrepresentable. Empty `segments` matches every `..`-free segment array ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049)) -- Add `narrow({ name, base, delta })` and `join({ name, refs })`, plus the `NarrowingDelta`, `NarrowOptions`, and `JoinOptions` types, for deriving a capability that forwards to a base under a narrower interface guard. **Both functions throw for now** — this release fixes their signatures so callers and deltas can be written against them, and the implementations follow ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049)) +- Add `narrow({ name, base, delta })`, which reads the base's interface guard over `E()` and returns an exo under the derived guard whose methods forward to the base. Narrowing a narrowing flattens: the result forwards straight to the original base under both deltas conjoined, so a chain of any depth stays one hop deep and costs one guard read. Every method of the result returns a promise ([#1052](https://github.com/MetaMask/ocap-kernel/pull/1052)) +- Add `join({ name, refs })`, plus the `NarrowingDelta`, `NarrowOptions`, and `JoinOptions` types, for deriving a capability that forwards to a base under a narrower interface guard. **`join` throws for now** — this release fixes its signature so callers and deltas can be written against it, and the implementation follows ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049)) - Add `getInterfaceMethodGuards`, `getMethodPayload`, `getGuardAt`, `buildMethodGuard`, and `asyncifyMethodGuards`, plus the `MethodGuardPayload` type, for reading an `@endo/patterns` interface guard by argument position — required arguments, then optionals, then the rest guard — and reassembling it ([#1048](https://github.com/MetaMask/ocap-kernel/pull/1048)) - Add `makeGuardedFetch` and the `FetchGuard` type, which wrap a `fetch` so that a guard runs before every request it makes, redirect hops included ([#1026](https://github.com/MetaMask/ocap-kernel/pull/1026)) - `redirect: 'follow'`, in the caller's `init` or on a `Request`, is overridden so that each hop can be checked; `manual` and `error` are honoured. `baseFetch` is therefore always called with `redirect: 'manual'` and must honour it diff --git a/packages/kernel-utils/src/narrow-interface-guard.test.ts b/packages/kernel-utils/src/narrow-interface-guard.test.ts index edead8f6b..0ac20bc5b 100644 --- a/packages/kernel-utils/src/narrow-interface-guard.test.ts +++ b/packages/kernel-utils/src/narrow-interface-guard.test.ts @@ -4,7 +4,10 @@ import { describe, it, expect } from 'vitest'; import { getInterfaceMethodGuards, getMethodPayload } from './guard-algebra.ts'; import type { MethodGuardPayload } from './guard-algebra.ts'; -import { narrowInterfaceGuard } from './narrow-interface-guard.ts'; +import { + conjoinDeltas, + narrowInterfaceGuard, +} from './narrow-interface-guard.ts'; import type { NarrowingDelta } from './narrow-interface-guard.ts'; // One fixture reaching all three positional categories. @@ -137,3 +140,46 @@ describe('narrowInterfaceGuard', () => { expect(() => narrowFrom(makeGuard(), delta)).toThrow(message); }); }); + +describe('conjoinDeltas', () => { + it('keeps only the methods the incoming delta names', () => { + const combined = conjoinDeltas({ read: [], write: [] }, { read: [] }); + + expect(combined).toStrictEqual({ read: [] }); + }); + + it('does not reinstate a method the existing delta dropped', () => { + expect(() => conjoinDeltas({ read: [] }, { write: [] })).toThrow( + 'Cannot narrow method "write": the base has no such method.', + ); + }); + + it('conjoins both patterns where the two deltas overlap', () => { + const combined = conjoinDeltas({ read: [M.lte(10)] }, { read: [M.gte(5)] }); + const [pattern] = combined.read!; + + expect(matches(7, pattern)).toBe(true); + expect(matches(2, pattern)).toBe(false); + expect(matches(20, pattern)).toBe(false); + }); + + it.each([ + { side: 'the existing delta', existing: [M.lte(10)], incoming: [] }, + { side: 'the incoming delta', existing: [], incoming: [M.lte(10)] }, + ])('carries a pattern held only by $side', ({ existing, incoming }) => { + const combined = conjoinDeltas({ read: existing }, { read: incoming }); + const [pattern] = combined.read!; + + expect(matches(7, pattern)).toBe(true); + expect(matches(20, pattern)).toBe(false); + }); + + it('leaves a position both deltas hole as unconstrained', () => { + const combined = conjoinDeltas( + { read: [undefined, M.lte(10)] }, + { read: [undefined] }, + ); + + expect(combined.read![0]).toBeUndefined(); + }); +}); diff --git a/packages/kernel-utils/src/narrow-interface-guard.ts b/packages/kernel-utils/src/narrow-interface-guard.ts index 1d0ce4fc6..a3dac3484 100644 --- a/packages/kernel-utils/src/narrow-interface-guard.ts +++ b/packages/kernel-utils/src/narrow-interface-guard.ts @@ -28,6 +28,44 @@ export type NarrowingDelta = Record; const conjoin = (base: Pattern, pattern: Pattern | undefined): Pattern => pattern === undefined ? base : M.and(base, pattern); +/** + * Conjoin two deltas, so that narrowing a narrowing is one delta against the + * original base. + * + * Keys come from `incoming` alone, since it drops what it does not name, and a + * key it names that `existing` does not is a method the narrowing being + * narrowed no longer has. At each position a hole on either side leaves the + * other in place. + * + * @param existing - The delta the base was narrowed by. + * @param incoming - The delta narrowing it further. + * @returns The combined delta. + */ +export const conjoinDeltas = ( + existing: NarrowingDelta, + incoming: NarrowingDelta, +): NarrowingDelta => { + const combined: NarrowingDelta = {}; + for (const [methodName, patterns] of Object.entries(incoming)) { + const inherited = existing[methodName]; + if (inherited === undefined) { + throw new Error( + `Cannot narrow method "${methodName}": the base has no such method.`, + ); + } + const length = Math.max(inherited.length, patterns.length); + combined[methodName] = Array.from({ length }, (_, index) => { + const left = inherited[index]; + const right = patterns[index]; + if (left === undefined) { + return right; + } + return conjoin(left, right); + }); + } + return combined; +}; + /** * Narrow one method guard by conjoining the delta's patterns onto the * positions they address. diff --git a/packages/kernel-utils/src/narrowing.test.ts b/packages/kernel-utils/src/narrowing.test.ts index 20bcef9cb..0cea21f52 100644 --- a/packages/kernel-utils/src/narrowing.test.ts +++ b/packages/kernel-utils/src/narrowing.test.ts @@ -1,18 +1,13 @@ import { matches } from '@endo/patterns'; import { describe, it, expect } from 'vitest'; -import { join, narrow, pathUnder } from './narrowing.ts'; +import { join, pathUnder } from './narrowing.ts'; const makeBase = (): object => ({ readFile: () => 'contents' }); -describe('narrow', () => { - it('is not implemented', async () => { - await expect( - narrow({ name: 'Scoped', base: makeBase(), delta: { readFile: [] } }), - ).rejects.toThrow('narrow is not implemented'); - }); -}); - +// `narrow` is exercised end to end in `@ocap/kernel-test`. It cannot be +// exercised here: `E` reads `globalThis.HandledPromise` when it loads, and this +// package's tests run under `mock-endoify`, which sets that to plain `Promise`. describe('join', () => { it('is not implemented', async () => { await expect( diff --git a/packages/kernel-utils/src/narrowing.ts b/packages/kernel-utils/src/narrowing.ts index d98533961..c5d853776 100644 --- a/packages/kernel-utils/src/narrowing.ts +++ b/packages/kernel-utils/src/narrowing.ts @@ -1,7 +1,16 @@ +// `E` comes from `@endo/captp`'s re-export because this package already depends +// on captp; `@endo/eventual-send` would be lighter but is not a dependency. +import { E } from '@endo/captp'; +import { GET_INTERFACE_GUARD, makeExo } from '@endo/exo'; import type { Guarded, Methods } from '@endo/exo'; import { M } from '@endo/patterns'; -import type { Pattern } from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; +import { getInterfaceMethodGuards } from './guard-algebra.ts'; +import { + conjoinDeltas, + narrowInterfaceGuard, +} from './narrow-interface-guard.ts'; import type { NarrowingDelta } from './narrow-interface-guard.ts'; /** @@ -20,23 +29,108 @@ export type JoinOptions = { refs: object[]; }; +type GuardBearer = { + [GET_INTERFACE_GUARD]: () => InterfaceGuard | undefined; +}; + +type Forwardable = Record unknown>; + +type Provenance = { + base: object; + delta: NarrowingDelta; + baseGuard: InterfaceGuard; +}; + /** - * Not yet implemented; throws. + * What each minted narrowing was minted from. `join` reads it to establish that + * its operands share a base, and `narrow` reads it to flatten a chain. + */ +const provenance = new WeakMap(); + +/** + * Build a method that forwards one call to the target over `E()`. + * + * `E()` answers every property with a method-invoker, so the index is optional + * only to the type system. Invoking one for a method the target lacks rejects, + * which is what a caller holding no such authority must see rather than a + * silent `undefined`. The invoker cannot be hoisted out of the call: `E()` + * refuses one invoked detached from the proxy that produced it. * + * @param target - The capability to forward to. + * @param methodName - The method to forward. + * @returns The forwarding method. + */ +const makeForwarder = + (target: object, methodName: string) => + async (...args: unknown[]): Promise => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return E(target as Forwardable)[methodName]!(...args); + }; + +/** * Conjoin each pattern of `delta` onto the corresponding argument position of * `base`'s interface guard, and return an exo under that derived guard whose * methods forward to `base`. * + * The base's guard is read over `E()` whether or not the base is local, so that + * one code path survives the base later being a cross-vat presence. Narrowing a + * narrowing needs no such read, since the first one cached what it fetched, and + * it flattens: the result forwards straight to the original base under the + * conjunction of both deltas, so a chain of any depth stays one hop deep and + * `join` reaches across the whole narrowing tree rather than between siblings + * only. + * + * Nothing checks arguments beyond the returned exo's own guard. + * * `Narrowed` describes the resulting method set, which is derived at runtime and - * so cannot be inferred; supply it to call the result through `E()`. + * so cannot be inferred; supply it to call the result through `E()`. Every + * method of the result returns a promise, since the derived guards are + * `M.callWhen`. * - * @param _options - The narrowing to mint. + * @param options - The narrowing to mint. + * @param options.name - The name for the derived exo and its interface guard. + * @param options.base - The capability to narrow. + * @param options.delta - The patterns to conjoin, by method and position. * @returns The narrowing of the base. */ -export const narrow = async ( - _options: NarrowOptions, -): Promise> => { - throw new Error('narrow is not implemented'); +export const narrow = async ({ + name, + base, + delta, +}: NarrowOptions): Promise> => { + const inherited = provenance.get(base); + const target = inherited?.base ?? base; + const combined = inherited ? conjoinDeltas(inherited.delta, delta) : delta; + const baseGuard = + inherited?.baseGuard ?? + (await E(base as GuardBearer)[GET_INTERFACE_GUARD]()); + if (baseGuard === undefined) { + throw new Error( + `Cannot narrow "${name}": the base has no interface guard.`, + ); + } + + const derivedGuard = narrowInterfaceGuard({ + name, + baseGuard, + delta: combined, + }); + const methods = Object.fromEntries( + Object.keys(getInterfaceMethodGuards(derivedGuard)).map((methodName) => [ + methodName, + makeForwarder(target, methodName), + ]), + ) as unknown as Narrowed; + + // The derived guard's method set is computed at runtime, so nothing ties it + // to `Narrowed` at the type level. + const narrowed = makeExo( + name, + derivedGuard as InterfaceGuard<{ [Method in keyof Narrowed]: MethodGuard }>, + methods, + ); + provenance.set(narrowed, { base: target, delta: combined, baseGuard }); + return narrowed; }; /**