fix(web): screen app-supplied sandbox permissions before granting them

The sandbox proxy's buildAllowAttribute() tests each permission key for
truthiness, so an untrusted `_meta.ui.permissions` bag could stand a
non-boolean in for a grant — `{ camera: "false" }`, a plausible way for a
server to mean "off", reads as on. Nothing screened it, because before
the `_meta.ui` fix no permissions reached the proxy at all.

Add approveSandboxPermissions alongside approveCspSources: keep only the
four keys the proxy knows, and only where the value is the spec's empty-
object marker or `true`; drop anything else (warning on a malformed
value, silently on an explicit `false`) and return undefined when nothing
survives. Both the notification and the hostCapabilities echo now carry
the approved set, so the app sees what was granted.

Also document what the listing lookup can and cannot see under
`paginatedLists`.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 21:22:14 -04:00
commit 45785a0aa6

View file

@ -1,4 +1,7 @@
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
import type {
McpUiResourceCsp,
McpUiResourcePermissions,
} from "@modelcontextprotocol/ext-apps/app-bridge";
/**
* Allowed shapes for a CSP source-expression supplied by an app's
@ -75,6 +78,65 @@ export function approveCspSources(
return approved;
}
/** The permission-key analog of {@link exhaustiveCspKeys}; same compile-time completeness proof. */
function exhaustivePermissionKeys<
const T extends readonly (keyof McpUiResourcePermissions)[],
>(
keys: T &
(keyof McpUiResourcePermissions extends T[number] ? unknown : never),
): T {
return keys;
}
const PERMISSION_KEYS = exhaustivePermissionKeys([
"camera",
"microphone",
"geolocation",
"clipboardWrite",
]);
/**
* Filter an app-supplied {@link McpUiResourcePermissions} down to the keys the
* host will forward to the sandbox proxy, keeping only those the app actually
* *requested*.
*
* The proxy's `buildAllowAttribute()` tests each key for **truthiness**, so an
* unvalidated bag lets a non-boolean stand in for a grant: `{ camera: "false" }`
* — a plausible way for a server to mean "off" — is truthy and would switch the
* `allow` attribute on. `_meta` is untrusted input, so the value is screened
* here rather than at the point of use. A key counts as requested when it
* carries the spec's empty-object marker (`camera: {}`), or `true` as the
* obvious shorthand; everything else — `false`, any string, `null`, a number —
* is dropped, as is any key outside the four the proxy knows.
*
* Returns undefined when nothing survives, so the notification and the
* `hostCapabilities.sandbox` echo carry no permissions at all rather than an
* empty object.
*/
export function approveSandboxPermissions(
permissions: McpUiResourcePermissions | undefined,
): McpUiResourcePermissions | undefined {
if (!permissions) return undefined;
const approved: McpUiResourcePermissions = {};
let granted = false;
for (const key of PERMISSION_KEYS) {
const requested: unknown = permissions[key];
if (
requested === true ||
(typeof requested === "object" && requested !== null)
) {
approved[key] = {};
granted = true;
} else if (requested !== undefined && requested !== false) {
console.warn(
`[mcp-app sandbox] dropping unrecognized "${key}" permission value:`,
requested,
);
}
}
return granted ? approved : undefined;
}
function joinSources(list: string[] | undefined, fallback: string): string {
return list && list.length > 0 ? list.join(" ") : fallback;
}