Skip to content

Receiver: fail-safe policy declaration; design record "Policy" section - #13

Merged
lannbot merged 1 commit into
mainfrom
policy-declaration
Sep 6, 2026
Merged

Receiver: fail-safe policy declaration; design record "Policy" section#13
lannbot merged 1 commit into
mainfrom
policy-declaration

Conversation

@lannbot

@lannbot lannbot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

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 sanitize transformer — but the seam it plugs into has to be safe to build on. A plain FrameSink wrapper 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:

  • Mutation stream (producer → receiver): undeclared or unknown field / op / enum value → PolicyError, stream aborts. Unknown tags are rejected, not skipped.
  • Event payloads (receiver → producer): undeclared field → not encoded. The receiver authors payloads; nothing to reject.
  • queries: undeclared → undefined / false.

SURFACE_V1 is 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 at compilePolicy, 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 Global value; non-strict parity on basic.pb; compilePolicy errors including Object.prototype names; SURFACE_V1 subset-of-current and frozen sizes. deno task check and deno task test green (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 mount from polyengine for frame-side consumers. Rust-side equivalents.

Automerge is armed.

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
lannbot merged commit 34eae64 into main Sep 6, 2026
1 check passed
@lannbot
lannbot deleted the policy-declaration branch September 6, 2026 17:56
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants