Receiver: fail-safe policy declaration; design record "Policy" section - #13
Merged
Conversation
An embedder hosting an untrusted producer needs to put a policy in front
of the receiver. A plain FrameSink wrapper fails open when the protocol
grows: the decoder skips unknown fields and forwards records whole, so a
new op, a new field on an existing message or a new enum value reaches
the wrapped receiver unchecked. This adds the one mechanism the design
record labels fail-safe: the embedder declares, by proto name, every
piece of surface it accepts, and the decoder enforces the declaration
before any value is consumed.
- receiver/src/policy.ts: Policy { accept, events, queries, sink? },
ALL_STREAM_FIELDS / ALL_EVENT_FIELDS / ALL_QUERIES name tables keyed
to the decoder's own field-number constants, frozen SURFACE_V1
snapshot (never grows), compilePolicy (unknown or removed names fail
at construction), PolicyError, queryAllowed.
- frames.ts: FrameDecoder(sink, { accept }) strict mode — one bit test
per field tag in every message; undeclared or unknown -> PolicyError.
Global enum gated by value. Without options, behaviour is unchanged.
- events.ts: encodePayload(name, ev, events?) — undeclared payload
fields are not encoded (the receiver authors payloads; drop, not
reject). Unfiltered output is byte-identical to before.
- mount.ts: MountOptions.policy wires all three directions; undeclared
queries answer undefined/false.
- docs/design.md: new "Policy" section (the allowlist is the embedder's,
not the protocol's; the seam, its abort contract, the declaration
mechanism, the evolution rule it imposes, and what the receiver does
not yet guarantee); open question 10 resolved with a pointer.
Tests: per-field gating over all 77 stream names and per-field drop over
all 35 event names, driven from fixture tables asserted to cover the
lists exactly; unknown tags; non-strict parity; frozen snapshot sizes.
lannbot
pushed a commit
that referenced
this pull request
Sep 6, 2026
…laration; asset handles Replaces #13's declaration model (accept/events/queries name lists, SURFACE_V1, compilePolicy, bitmask strict decoding, event-field filter) with a smaller mechanism of the same fail-safe property; keeps #14's hardening and driver split. - Policy { version, check(op), query?(name) }. `version` pins the `PROTOCOL VERSION` from the proto header; createDriver refuses any other, so a receiver upgrade cannot silently widen what a policy reviewed. `check` sees createElement/setAttribute/setProperty/ addListener/bindMarker with interned strings resolved and the element's tag (tracked through clone-template/bind-path); templates are checked once at register-template, flattened into the same shapes. A rejection aborts the stream with a PolicyError. `query` gates the WIT queries; refusal answers none/false. - Strict decoding whenever a policy is present: one boolean per skip branch; unknown ops, fields and enum values reject. - `PROTOCOL VERSION: 1` in the proto header, mirrored as stream_dom_proto::PROTOCOL_VERSION (build.rs) and the receiver's PROTOCOL_VERSION (test re-reads the .proto). - SetAttribute.value / TemplateAttr.value gain an `asset` arm resolved by `resolveAsset`; wire-compatible, bench baseline unchanged. Batch::set_attribute_asset on the Rust side. - Backends and PolicySink pin template tags, attribute names and values at registration, so a later re-intern cannot make the applied DOM diverge from what the policy approved. remote.ts now rejects an un-interned template ref at registration, as native always has. - events.ts restored byte-for-byte to its pre-#13 form. - docs/design.md: Policy section rewritten for this model (records the declaration alternative and why it was removed); new "Assets are handles" decision; open question 10 updated.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
An embedder hosting an untrusted producer (a plugin, a sandboxed app) needs a policy in front of the receiver. The design record now says plainly that the allowlist itself is the embedder's — this repo ships no vocabulary and no
sanitizetransformer — but the seam it plugs into has to be safe to build on. A plainFrameSinkwrapper is not: the decoder skips unknown fields and forwards records whole, so a new op, a new field on an existing message (Listener.once), or a new enum value passes through a wrapper that has no case for it. Type checks don't close this (optional additions are type-compatible; JS consumers have none).What
The one mechanism labelled fail-safe: the embedder declares, by proto name, every piece of surface it accepts, and the decoder enforces it before consuming any value. Three directions, two behaviours:
PolicyError, stream aborts. Unknown tags are rejected, not skipped.queries: undeclared →undefined/false.SURFACE_V1is a frozen snapshot of today's full surface;{ ...SURFACE_V1, sink }is the one-line policy, and it does not move when the protocol grows. Unknown or removed names fail atcompilePolicy, not at first frame. Not a compatibility promise — only that updating the receiver cannot silently widen exposure.receiver/src/policy.ts(new):Policy, name tables keyed to the decoder's field-number constants,SURFACE_V1,compilePolicy,PolicyError,queryAllowed.frames.ts:FrameDecoder(sink, { accept })strict mode, one bit test per field tag. Without options: unchanged.events.ts:encodePayload(name, ev, events?)field filter. Unfiltered: byte-identical.mount.ts:MountOptions.policy.docs/design.md: "Policy" section; open question 10 resolved; the Encoding paragraph's "receivers skip what they do not know" qualified.Tests
15 new: per-field gating over all 77 stream names and per-field drop over all 35 event names, each driven from a fixture table asserted to cover the list exactly (so an unmapped name fails); unknown Frame / sub-message tags and unknown
Globalvalue; non-strict parity onbasic.pb;compilePolicyerrors includingObject.prototypenames;SURFACE_V1subset-of-current and frozen sizes.deno task checkanddeno task testgreen (60 passed).Not in this PR
Receiver hardening against malformed streams (unresolvable ids, cycles, out-of-range refs, prototype-walking property names) — named in the design section as still open. Decoupling
mountfrom polyengine for frame-side consumers. Rust-side equivalents.Automerge is armed.