fix(apps): fail closed on a malformed app ui/initialize

The capability observer recorded `params.appCapabilities` from ANY frame whose
method was `ui/initialize`, before the bridge validated it. A view could
therefore send a second, malformed initialize — one the bridge rejects, keeping
the capabilities it had already accepted — and flip `elicitation` on in this
gate, after which the host would forward an elicitation the bridge never
negotiated.

Now only a well-formed initialize REQUEST is recorded (a JSON-RPC id, plus the
handshake's required `protocolVersion` and `appInfo`), and only the first one:
the bridge keeps what it accepted and ignores re-initialization, so this gate
agrees rather than offering a second, laxer path to the same flag. A malformed
first frame records nothing.

This is a gate, not a second copy of the bridge's schema — the bridge stays the
authority on the rest of the frame.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 20:45:28 -04:00
commit cc0770babd

View file

@ -39,20 +39,53 @@ export interface MessageObservable<TMessage = unknown> {
}
/**
* Record `params.appCapabilities` if `message` is a `ui/initialize` frame.
* Narrows from `unknown` rather than asserting a shape: the frame is whatever
* the sandboxed view posted.
* Whether `message` is a well-formed `ui/initialize` REQUEST — a JSON-RPC
* request id, plus the two params the handshake requires (`protocolVersion`,
* `appInfo`).
*
* This is a gate, not a second copy of the bridge's schema: the bridge stays
* the authority on everything else in the frame. What it has to stop is a
* *malformed* initialize the bridge will reject, since accepting one here
* would let a view flip `elicitation` on through a frame that never negotiated
* anything. Narrows from `unknown` throughout — the frame is whatever the
* sandboxed view posted.
*/
function isInitializeRequest(
message: unknown,
): message is { params: { appCapabilities?: unknown } } {
if (typeof message !== "object" || message === null) return false;
const frame = message as { id?: unknown; method?: unknown; params?: unknown };
if (frame.method !== "ui/initialize") return false;
// A notification (no id) is not the handshake request.
if (frame.id === undefined || frame.id === null) return false;
if (typeof frame.params !== "object" || frame.params === null) return false;
const params = frame.params as {
protocolVersion?: unknown;
appInfo?: unknown;
};
return (
typeof params.protocolVersion === "string" &&
typeof params.appInfo === "object" &&
params.appInfo !== null
);
}
/**
* Record `params.appCapabilities` from the view's handshake.
*
* Only the FIRST well-formed `ui/initialize` is recorded, mirroring the bridge:
* it keeps the capabilities it accepted and ignores re-initialization, so a
* later frame — malformed or not — must not be able to change what this gate
* reports either. Fail-closed in both directions: a malformed first frame
* records nothing.
*/
function recordAdvertisedCapabilities(
bridge: AppBridge,
message: unknown,
): void {
if (typeof message !== "object" || message === null) return;
const frame = message as { method?: unknown; params?: unknown };
if (frame.method !== "ui/initialize") return;
if (typeof frame.params !== "object" || frame.params === null) return;
const advertised = (frame.params as { appCapabilities?: unknown })
.appCapabilities;
if (rawAppCapabilities.has(bridge)) return;
if (!isInitializeRequest(message)) return;
const advertised = message.params.appCapabilities;
if (typeof advertised === "object" && advertised !== null) {
rawAppCapabilities.set(bridge, advertised as Record<string, unknown>);
}