BotsKit — Trust model · why you can audit this in one sitting

The whole point of a bookmarklet is that the trust surface is one short file you read once. Everything past it updates without forcing you to re-trust. This page walks the rings of trust, contrasts the two axes, and is honest about what a browser RPC layer cannot defend against.

1The anchor is the audit

A bookmarklet's source is what you install. It can't auto-update behind your back.

When you drag a BotsKit bookmarklet onto your bar, the browser saves the JavaScript verbatim in your bookmarks. From that moment on:

Open the control room and expand "Read the trust anchor" under any axis card — the entire surface scrolls past in one panel. That is the entire audit.

Implication. A change to the anchor's behavior requires a re-drag. There is no silent over-the-air update path. The collar, uApps, and capabilities behind it can update freely without re-trusting because they cannot exceed what the anchor permits.

2Three rings of trust

The anchor admits the collar admits the uApp. Each ring constrains the next.

Ring 1 · audited once

Anchor (bookmarklet)

The single file you dragged. Validates origins, sources, sessions, nonces, kinds, and sizes on every postMessage before any handler runs (plan §3). Holds the grant table — default-deny.

Authority: total within its trust boundary; can mint or revoke caps.

Ring 2 · loads from our origin

Collar

Broker that lives at the HUB origin. Enforces grant chips — small toggles on the host-page panel labelled "read page", "show overlay", "screenshot", "share URL". The chip is the consent. A capability call (iv("rd", …)) without a flipped chip returns DENIED.

Authority: only what the chips currently permit. Updates land here without re-dragging.

Ring 3 · downstream

uApp (chat, future widgets)

Where the actual conversation happens. Talks to the collar through the four caps it was granted. Can never reach the host page directly — only through the collar's gate.

Authority: a strict subset of what the user has chip-granted to the collar.

The four caps you'll see chips for today:

capwhat it askswhat it returns
rdRead the host page's selection or visible text.Plain text, capped at 64 KiB per call.
ovDraw an overlay annotation on the host page.Acknowledgement; the overlay is host-DOM-only, no script.
scCapture a screenshot of the visible host viewport.A PNG data URL.
loShare the host page URL with the chat (origin + path + hash; no query string).An object — sanitization is in the anchor itself, not the collar.

3Axis A vs Axis B — same rings, different shapes

One runtime question — "can our widget iframe into this page?" — forks the whole trust shape.

Axis A · bookmarklet.exit — nested-iframe control

Fires when the host's CSP frame-src permits us.

  • Anchor injects an iframe at the HUB origin (the collar) into the host page.
  • Collar nests another iframe (the uApp) inside itself.
  • Trust flows downward: each frame can only ask its parent for what its parent's chip table says yes to.
  • Host page DOM only gets touched through caps the user enabled.
  • X-Frame-Options is not in play here — we never iframe the host page. We load our own origin into the host page, which the host's CSP can permit or deny.

Axis B · bookmarklet.stream — pixel quarantine

Fires when the host's CSP would block our iframe.

  • Anchor opens a host_base window/tab at the HUB origin (top-level, never inside the host page).
  • The real collar+uApp render there, at our origin, fully isolated.
  • A small <canvas> + postMessage tick is the host page's only footprint. PNG frames stream in; clicks/keystrokes replay back via injector.js.
  • There is no DOM bridge — the streamed widget literally cannot reach into the host page. What you see is pixels.
  • Same chip discipline applies inside the host_base. The collar there enforces grants identically.
Why two anchors instead of one auto-cascading body? Spec §3, R6: no silent fallback. The install page (or a CSP probe) asks you to pick. A choice you made beats a choice the bookmarklet made for you.

4What the trust anchor enforces, line by line

Every postMessage is filtered against this quadruple before reaching any handler.

checkagainstwhat it stops
originallowedOrigins allowlistCross-origin window posts unsolicited messages into our handler. (Threat T1.)
sourceexpectedSource WindowProxyWrong window in our own page (sibling iframe, opener). (Threat T2.)
sessionopaque 128-bit randomStale messages from a prior bus. Anyone holding it is inside the bus — keep it confidential.
nonceopaque 128-bit randomSame. Rotated on transport upgrade in v1.
kindknown protocol verbsUnknown / app-level fields at the envelope root.
sizemaxEnvelopeBytes (~256 KiB)Oversized payload DoS before JSON.stringify ever runs.
cap + grantlocal grant table, default-denyPeer invokes a capability we never granted. (Threat T3.)

For the full audit, see plan_caprpc.md — sections G1–G12 (grant audit) and F1–F10 (per-file findings) are the receipts.

5What this cannot defend against

A capability bus is one layer. Here's the honest line between in-scope and out-of-scope.

✓ In scope (we defend against)

  • Cross-origin windows posting to our handler.
  • Wrong window in our own page (sibling iframe, opener).
  • Peer invoking a cap we never granted.
  • We accidentally invoking a peer cap they never granted.
  • Stale grants surviving policy change (expiresAt lease).
  • Oversized payload DoS (envelope-size cap).
  • Replay within the same tab (random 128-bit message IDs).
  • OAuth confused-deputy (separate one-shot helper, mandatory iss + state).

✗ Out of scope (we explicitly do not defend against)

  • XSS in your own origin — if attacker runs JS on the host page, they own everything. No browser RPC layer changes that.
  • Malicious browser extension with <all_urls> can read all message events. Don't transit secrets through postMessage if extensions are in scope.
  • Devtools / a user with the JS heap. They are inside every boundary by definition.
  • Compromised peer iframe within the trust boundary. Once granted, a cap is callable as we said.
  • Network-layer attacks (TLS, DNS) — browser/OS responsibility.
  • Side-channel timing across origins — out of scope for an RPC layer.

The honest summary. BotsKit defends the seam between the host page and our widget, and the seam between widget components. It does not turn an unsafe page into a safe one. CSP, COOP/COEP, Subresource Integrity, and code review of every addEventListener("message", …) are still your job. What we promise is that our listener is one of the audited good ones, and that the bookmarklet anchor you dragged once is the only thing standing between us and the page.

6Want to read the source?

Three artifacts, in increasing order of size:

And the prose receipts: