RPC Trust Gap
Findings snapshot — 2026-07-27. Investigation notes feeding a future planning session, not a decision. Captures why the tranquil-rpc runtime is presently injected into no page at all, so that session can start from the diagnosis. See the Security Considerations living threat model and the Guest↔Host RPC architecture doc.
Symptom
During normal browsing the dev console logs, per navigation:
[tranquil-rpc] untrusted — no runtime injected: https://en.wikipedia.org/wiki/Tom_M._Mitchell
[tranquil-rpc] untrusted — no runtime injected: https://claude.com/blog/... This is correct behaviour, not a bug — those are hostile remote https:// pages and the trust
model is deliberately default-deny. The log is our own (tranquil-rpc/lib/host.js, isTrusted() false path), firing on every non-trusted load. It surfaced only because we tightened the dev-console
noise filter; the underlying behaviour predates it.
The real issue it exposes: the runtime is injected nowhere, including on content we do want trusted.
The trust model (as built)
tranquil-rpc/lib/trust.js is security-critical and default-deny. A URL is trusted only if:
- its scheme is
file:— every other scheme (http,https,data,blob,about, …) is never trusted (isTrusted()returnsfalseat the protocol check), and - its resolved filesystem path is at, or under, a registered trusted root (exact path or a real child segment — no bare string-prefix matches).
Trusted pages get the injected guest runtime plus the full HostApi; untrusted pages get nothing. This strictness is intentional: a bug here hands host capabilities to hostile remote pages.
Two independent gaps
Gap 1 — no trusted roots are ever registered. addTrustedRoot() is exported from trust.js (and re-exported via lib/index.js) but nothing calls it. With an empty root set, isTrusted() is false for everything, including our own local content. Activation confirms it:
[tranquil-rpc] activated; trusted roots: [] Gap 2 — the model is file://-only, but our own app pages are served over HTTP. The
app-template and automations content is served by browser-sync over http://localhost:<port>, a
scheme the classifier rejects by design. So even if Gap 1 were fixed by registering a root, those
pages still could not be trusted — trusting a localhost origin is a different, security-sensitive
mechanism (any local process bound to that port could then impersonate a trusted page).
Net effect: the RPC runtime is currently injected on no page. Remote pages are correctly denied; our own pages are denied too, for the two reasons above.
The decision a fix requires
The crux is a design choice — how do we identify and serve trusted content? — not a small patch. Sketch of the option space, cheapest first:
- Serve trusted pages from
file://and register those roots. Stays entirely within the current model; the change is callingaddTrustedRoot()for the right directories and (if needed) serving our trusted app content from disk rather than browser-sync. Smallest blast radius. - Extend the trust model to a specific
localhostorigin. Lets browser-sync-served pages be trusted, but weakens the boundary — needs a story for why that origin can’t be impersonated by another local process (fixed port + loopback is not sufficient on its own). - Token / handshake-based trust. The host hands a freshly minted secret to content it served and
accepts RPC only from holders of that secret. Most flexible, most work; touches
trust.js, the ADR, and the threat model.
Any of these changes the security boundary of the entire RPC layer, so it belongs in its own session with the threat model in front of us.
References
tranquil-rpc/lib/trust.js— the classifier (isTrusted,addTrustedRoot,trustedRoots).tranquil-rpc/lib/host.js— theisTrusted(url)gate and the “untrusted — no runtime injected” log; the activation log that prints the (empty) trusted roots.tranquil-automations/lib/pane-controls-capability.js— the one liverpc.isTrusted(...)consumer.- ADR-0003: Cap’n Web RPC · ADR-0010: Trust-tiered pane controls
- Security Considerations — fold any resolution back into that living doc.