From 2c386ec1a93b8e81e138516c036062aca234f8dc Mon Sep 17 00:00:00 2001 From: ci-belphegor <324126930+ci-belphegor@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:32:53 -0400 Subject: [PATCH] feat(kernel-utils): declare the narrowing API surface Fix the signatures of narrow and join and export them alongside a fully implemented pathUnder, so that callers and deltas can be written against the API before the algebra behind it exists. Both narrow and join throw; the changelog says so. pathUnder([]) matches every ..-free segment array rather than throwing. It is the top of the prefix lattice and a well-defined element of the vocabulary; a capability for which unbounded authority is a mistake rejects an empty prefix in its own config validation, where throwing here would buy nothing anyway since the caller could write the pattern by hand. Co-Authored-By: Claude Opus 5 --- packages/kernel-utils/CHANGELOG.md | 2 + packages/kernel-utils/src/index.test.ts | 3 + packages/kernel-utils/src/index.ts | 6 ++ packages/kernel-utils/src/narrowing.test.ts | 56 +++++++++++++++ packages/kernel-utils/src/narrowing.ts | 80 +++++++++++++++++++++ 5 files changed, 147 insertions(+) create mode 100644 packages/kernel-utils/src/narrowing.test.ts create mode 100644 packages/kernel-utils/src/narrowing.ts diff --git a/packages/kernel-utils/CHANGELOG.md b/packages/kernel-utils/CHANGELOG.md index 8ee72c9446..ccc0db808b 100644 --- a/packages/kernel-utils/CHANGELOG.md +++ b/packages/kernel-utils/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 `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/index.test.ts b/packages/kernel-utils/src/index.test.ts index 9d7b868c35..fd3c7fe6fb 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -31,6 +31,7 @@ describe('index', () => { 'isTypedArray', 'isTypedObject', 'isVatBundle', + 'join', 'jsonSchemaToStruct', 'makeCounter', 'makeDefaultExo', @@ -39,6 +40,8 @@ describe('index', () => { 'makeGuardedFetch', 'mergeDisjointRecords', 'methodArgsToStruct', + 'narrow', + 'pathUnder', 'prettifySmallcaps', 'resolveFetchInput', 'retry', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index bd57de4394..5fdeecf58f 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -8,6 +8,12 @@ export { getMethodPayload, } from './guard-algebra.ts'; export type { MethodGuardPayload } from './guard-algebra.ts'; +export { join, narrow, pathUnder } from './narrowing.ts'; +export type { + JoinOptions, + NarrowOptions, + NarrowingDelta, +} from './narrowing.ts'; export { GET_DESCRIPTION, makeDiscoverableExo } from './discoverable.ts'; export type { DiscoverableExo } from './discoverable.ts'; export { S } from './described.ts'; diff --git a/packages/kernel-utils/src/narrowing.test.ts b/packages/kernel-utils/src/narrowing.test.ts new file mode 100644 index 0000000000..20bcef9cb5 --- /dev/null +++ b/packages/kernel-utils/src/narrowing.test.ts @@ -0,0 +1,56 @@ +import { matches } from '@endo/patterns'; +import { describe, it, expect } from 'vitest'; + +import { join, narrow, 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'); + }); +}); + +describe('join', () => { + it('is not implemented', async () => { + await expect( + join({ name: 'Joined', refs: [makeBase(), makeBase()] }), + ).rejects.toThrow('join is not implemented'); + }); +}); + +describe('pathUnder', () => { + it.each([ + { specimen: ['srv', 'data'], expected: true }, + { specimen: ['srv', 'data', 'x'], expected: true }, + { specimen: ['srv', 'data', 'a', 'b'], expected: true }, + { specimen: ['srv'], expected: false }, + { specimen: ['srv', 'logs'], expected: false }, + { specimen: ['var', 'data', 'x'], expected: false }, + { specimen: ['srv', 'data', '..', 'logs'], expected: false }, + { specimen: ['srv', 'data', 1], expected: false }, + { specimen: 'srv/data', expected: false }, + ])( + 'matches $specimen under a prefix: $expected', + ({ specimen, expected }) => { + expect(matches(specimen, pathUnder(['srv', 'data']))).toBe(expected); + }, + ); + + it.each([ + { specimen: [], expected: true }, + { specimen: ['etc', 'passwd'], expected: true }, + { specimen: ['..'], expected: false }, + ])( + 'matches $specimen under no prefix: $expected', + ({ specimen, expected }) => { + expect(matches(specimen, pathUnder([]))).toBe(expected); + }, + ); + + it('excludes .. only past the prefix', () => { + expect(matches(['..', 'etc'], pathUnder(['..']))).toBe(true); + }); +}); diff --git a/packages/kernel-utils/src/narrowing.ts b/packages/kernel-utils/src/narrowing.ts new file mode 100644 index 0000000000..7831f7aaf8 --- /dev/null +++ b/packages/kernel-utils/src/narrowing.ts @@ -0,0 +1,80 @@ +import type { Guarded, Methods } from '@endo/exo'; +import { M } from '@endo/patterns'; +import type { Pattern } from '@endo/patterns'; + +/** + * A narrowing, addressed by method name and then by argument position. + * + * A method absent from the delta is dropped from the narrowing. An `undefined` + * slot leaves that argument position as the base has it. + */ +export type NarrowingDelta = Record; + +/** + * `base` is `object` rather than `Methods` because an `@endo/exo` carries no + * index signature and so does not satisfy `Methods`. A promise for a base is + * acceptable too, since the forward goes through `E()`. + */ +export type NarrowOptions = { + name: string; + base: object; + delta: NarrowingDelta; +}; + +export type JoinOptions = { + name: string; + refs: object[]; +}; + +/** + * Not yet implemented; throws. + * + * 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`. + * + * `Narrowed` describes the resulting method set, which is derived at runtime and + * so cannot be inferred; supply it to call the result through `E()`. + * + * @param _options - The narrowing to mint. + * @returns The narrowing of the base. + */ +export const narrow = async ( + _options: NarrowOptions, +): Promise> => { + throw new Error('narrow is not implemented'); +}; + +/** + * Not yet implemented; throws. + * + * Return a narrowing of `refs`' common base admitting exactly what any of them + * admits. Every ref must be one this module minted from that base, or the base + * itself. + * + * @param _options - The join to mint. + * @returns The join of the refs. + */ +export const join = async ( + _options: JoinOptions, +): Promise> => { + throw new Error('join is not implemented'); +}; + +/** + * Build a pattern matching segment arrays under a prefix, excluding `..` so that + * traversal out of the prefix is unrepresentable. + * + * Empty `segments` matches every `..`-free segment array, which is the top of + * the prefix lattice. A capability for which unbounded authority is a + * configuration mistake rejects it at its own config boundary, not here. + * + * @param segments - The prefix the matched arrays must start with. + * @returns A pattern over segment arrays. + */ +export const pathUnder = (segments: string[]): Pattern => + M.splitArray( + segments.map((segment) => M.eq(segment)), + [], + M.arrayOf(M.and(M.string(), M.not(M.eq('..')))), + );