Repository navigation
feat(kernel-utils): attenuation by narrowing - #1134
Conversation
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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 <noreply@anthropic.com>
Derive the interface guard of a narrowing: conjoin each delta pattern onto the base's guard at the argument position it addresses, inherit arity, the required/optional/rest split, and return guards verbatim, drop methods the delta does not name, and asyncify every method guard for forwarding. The guard is constructed as a conjunction with the base's own rather than checked against it, so it admits no call the base does not. That is the precondition join needs to disjoin two deltas without a pattern subtyping decision procedure. A delta naming a method the base guards by default still throws; PR-8 relaxes that arm into synthesizing a guard from the delta. No exos, promises, or provenance — narrow wraps this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Return a narrowing of the refs' common base admitting whatever any of them admits: the method set is the union of the operands', and where two name the same method each argument position is disjoined. disjoinDeltas sits beside conjoinDeltas as its dual, and the asymmetry is the whole missing-method rule. A join is a union of authority, so its keys are the union of both sides: a method absent from an operand contributes the empty set and survives at the other operand's delta, while a hole contributes everything and leaves that position unconstrained. Narrowing is the opposite, which is why conjoinDeltas takes its keys from one side. The base is a legal operand and absorbs, so the lattice has a representable top and a fold needs no special case. It is recognizable only by identity against a minted ref's record, so a call whose refs are all unminted throws rather than trusting one of them. The result records the same base and the disjoined delta, so a join can be narrowed or joined again. narrow and join now share the minting step, since join needs the same derive-forward-record sequence against a guard it already holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A base built with makeDefaultExo carries defaultGuards: 'passable' and an empty methodGuards map, so there is no per-method guard to conjoin onto. Since 'passable' admits any passable arguments, the delta's patterns can be the whole guard: the result admits no more calls than the base did, and a delta of length 0 synthesizes M.callWhen().rest(M.any()).returns(M.any()), which admits exactly what the base admits. That makes narrow usable against the exos this repo actually builds. The base names no methods, so a delta naming one it does not implement is indistinguishable from one it does and can no longer be refused while narrowing. Calling it rejects with 'target has no method', which is what a caller holding no such authority must see. The error for a base that names its methods is unchanged, as is the one for raw defaults. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cover the narrowing library end to end in a real vat, where E and the exo guards behave as in production rather than under mock-endoify. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A synthesized guard made every delta position required, holes included. A join pads the shorter operand's delta with holes, so it demanded arguments that operand never required and refused calls it admitted. Trailing holes are now dropped. A base that guards methods by default contributed an empty delta as a join operand, so it did not absorb, and the join kept the other operands' constraints. Its methods cannot be enumerated, so no delta represents it, and join now throws for such a base. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f6172fa to
e0ec8cb
Compare
join disjoined deltas position by position, so the result admitted the product of the per-position unions rather than the union of the operands. Joining narrowings to copy(data, data) and copy(logs, logs) admitted copy(data/x, logs/y), which neither operand admits. A narrowing now records its delta in disjunctive normal form: each method maps to a non-empty list of positional rows, and admits a call that any row admits. narrow's input keeps its single-row shape and is lifted to one row. join concatenates the operands' rows per method, with a row of holes absorbing the method's other rows, and narrowing a join conjoins the incoming row onto every row. A method with one row renders the same positional guard as before. One with several renders as M.callWhen().rest(M.or(...)) with one M.splitArray per row, each conjoined with the base's guard at every position. A method guard's rest guard matches the trailing arguments as one array, so with no fixed arguments it sees the whole argument array. M.splitArray defaults an omitted rest to M.any(), so a row whose base has no rest guard gets [] as its rest to refuse extra arguments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n a vat Cover a join of two copy narrowings rejecting the cross-combination of their arguments, a narrowed join admitting and rejecting per row, and a narrowed join recording the original base, shown by joining it with that base. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
join now admits exactly the union of what its operands admit. Describe the row representation, how narrowing distributes over rows, and the rendered guard for a method with several rows, which a receiver in another vat sees in place of the positional form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A method guard's rest guard matches the trailing arguments as one array, but narrowing conjoined each pattern past the fixed arity onto it as though it matched each argument. A pattern such as M.lte(10) then never matched the array, so the narrowed method refused every call, and several rest positions collapsed onto one constraint. The patterns at rest positions are now gathered into M.splitArray([], [p, ...]) over the trailing array and conjoined onto the base's rest guard. Each sits in an optional slot, so it constrains its own argument when present without changing arity, and arguments past the last pattern are left as the base has them. A row whose patterns stop before the rest guard keeps it unchanged. Each position is now an independent constraint on one argument, so a row is a conjunction of per-argument constraints, and a join, which only disjoins whole rows, still admits exactly what its operands admit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Almost there. Also the HandledPromise problem is real, but it doesn't mean narrow can only be tested inside a vat. @ocap/kernel-test already runs under real lockdown (endoify-node), so a plain test file there can import narrow and join and call them directly, with no kernel or vat. That's also a cheap place to test cases that should be refused, which the current vat tests mostly skip.
Cases with no test at the moment:
- Narrowing a narrowing: re-adding a dropped method throws, and the first narrowing's patterns still apply.
joinwith refs from different bases, or with an object that is not a narrowing: both should throw.- A joined ref refuses calls that none of its operands allows. Right now only an allowed call is checked.
joinwith the base as one of the refs allows everything the base allows.- Changing a delta after
narrowhas no effect.
As it stands, deleting the ref !== base check in join, or passing delta instead of combined to mint in narrow, would not fail any test.
A narrowing's record held the caller's row arrays, so changing a delta after narrow changed the record, and a later join or narrow rendered its guard from the changed delta. A delta narrowed to srv/data and then overwritten with M.any() let a join read etc/passwd. Rows are now copied when lifted into the record, and the record is hardened when minted. Method lookups read inherited properties, so a method named after an Object.prototype member, such as toString, crashed conjoinDeltas and disjoinDeltas, and was taken for a guarded method of a default-guarded base rather than synthesized. Lookups now use Object.hasOwn. Filling holes with M.any() used map, which skips the holes of a sparse array such as [, M.lte(10)], leaving holes in a guard's patterns. Holes are now filled with Array.from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ance narrow and join looked up a narrowing's record by the value they were given, so a promise for a narrowing found no record. Narrowing it read the narrowing's guard as though it were an original base, so the result did not flatten, and joining it with its siblings threw because the refs did not share a base. Both now await what they are given first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 939aa7f. Configure here.
Since narrow began awaiting its base, it copied the delta only after that await, so a caller changing the delta in the same turn, or reusing it for a second narrow, had the change recorded and a later join minted from it. narrow now hardens the delta on entry, before any await, which also freezes any mutable patterns it holds until the guard is built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover narrow and join without a kernel or vat, including the calls and joins they must refuse: both deltas of a chain applying, restoring a dropped method, the recorded base, a promise for a narrowing, a delta changed before or after narrow settles, a join refusing the cross-combination of its operands' arguments, the base absorbing, and refs from different bases, refs that are neither a narrowing nor the base, and a default-guarded base. The vat test for a delta changed after narrowing moves here, since narrow now hardens the delta and changing it throws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@sirtimid, re your review: 0e41efc adds |

Explanation
Adds attenuation by narrowing to
@metamask/kernel-utils, as specified indocs/attenuation-by-narrowing.md:narrow({ name, base, delta })reads the base's interface guard overE(), conjoins the delta's per-position patterns onto it, and returns an exo under the derived guard that forwards to the base. Narrowing a narrowing flattens to one hop over the original base.join({ name, refs })returns a narrowing of the refs' common base admitting whatever any of them admits.pathUnder(segments)is a pattern for segment arrays under a prefix, with..excluded past it.makeDefaultExo(defaultGuards: 'passable', no method guards) can be narrowed: each named method gets a synthesized guard.The commits are kept as review steps: API surface, the pure guard algebra (
narrowInterfaceGuard, internal),narrow,join, default-guarded bases, then an end-to-end test in a real vat. The squashed result has no stubs and noit.failscases.Notes for reviewers
baseis typedobject. A real exo satisfies neitherMethodsnorPartial<Methods>, sinceGuarded<M>has no index signature. As a result,E()method access on a narrowing needs an explicit type parameter, e.g.narrow<{ read: (p: string[]) => Promise<string> }>(...).narrowbuildsAND(guard(base), delta), soguard(narrowed) ≤ guard(base)holds without a pattern subtyping procedure.joinrelies on this: every operand must be minted bynarrowfrom the same base (or be that base, which absorbs), so OR-ing the deltas stays within the base. A ref from anywhere else throws, as does a join whose refs are all unminted.conjoinDeltastakes keys from the incoming delta only and throws on a key the existing narrowing lacks, so re-narrowing cannot restore a dropped method. A position landing in a rest guard constrains every trailing argument. An empty delta for a method is a no-op.E(target)[method]out of it fails withUnexpected receiver. The JSDoc records this.target has no method.defaultGuards: 'raw'still throws, because raw arguments are not "any passable".narrowhas no unit tests inkernel-utils.EbindsglobalThis.HandledPromiseat load, andmock-endoifysets that to plainPromise. The cases in@ocap/kernel-testcover it in a vat instead.References
Checklist
🤖 Generated with Claude Code
Note
Medium Risk
New security-sensitive capability plumbing (guard derivation, join soundness, provenance); mistakes could admit calls operands would reject, though the design builds conjunctions rather than verifying subtyping.
Overview
Adds capability attenuation by narrowing to
@metamask/kernel-utils:narrow,join, andpathUnder, plusNarrowingDelta/ options types, exported from the main package entry.narrowreads the base’s interface guard (viaE()), conjoins per-method positional patterns, hardens the delta, and mints a forwarding exo with provenance so chains flatten to one hop over the original base.joinunions authority from refs sharing that base by concatenating disjunctive rows per method (not per-position OR), rendering multi-row methods as rest-guard disjunctions so cross-argument combinations are never admitted. Guard construction lives innarrow-interface-guard.ts(including default-guardedmakeDefaultExobases and rest-position handling viaM.splitArray).Documentation in
docs/attenuation-by-narrowing.mdis updated to match (join DNF, flattening, rest args) and notes the library is still in progress. Coverage includes large unit tests for the guard algebra, directnarrow/jointests under lockdown, and kernel vat probes for end-to-end behavior.Reviewed by Cursor Bugbot for commit 0e41efc. Bugbot is set up for automated code reviews on this repo. Configure here.