feat(mcp-apps): add Permission Policy support for sandbox iframes (#6947)

This commit is contained in:
Andrew Harvard
2026-02-04 11:52:11 -05:00
committed by GitHub
parent d8d9bb741b
commit 2b90c2c310
10 changed files with 110 additions and 8 deletions
+1
View File
@@ -576,6 +576,7 @@ derive_utoipa!(Icon as IconSchema);
goose::goose_apps::WindowProps,
goose::goose_apps::McpAppResource,
goose::goose_apps::CspMetadata,
goose::goose_apps::PermissionsMetadata,
goose::goose_apps::UiMetadata,
goose::goose_apps::ResourceMetadata,
super::routes::dictation::TranscribeRequest,
@@ -33,7 +33,7 @@
let guestIframe = null;
function createGuestIframe(html) {
function createGuestIframe(html, permissions) {
if (guestIframe) {
guestIframe.remove();
}
@@ -46,6 +46,17 @@
// allow-forms: needed if the app has forms
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms');
// Build Permission Policy allow attribute from requested permissions
// These control access to sensitive browser APIs like camera, microphone, etc.
var allowList = [];
if (permissions && permissions.camera) allowList.push('camera');
if (permissions && permissions.microphone) allowList.push('microphone');
if (permissions && permissions.geolocation) allowList.push('geolocation');
if (permissions && permissions.clipboardWrite) allowList.push('clipboard-write');
if (allowList.length > 0) {
guestIframe.setAttribute('allow', allowList.join('; '));
}
guestIframe.srcdoc = html;
guestIframe.style.cssText = 'width:100%; height:100%; border:none;';
@@ -73,8 +84,9 @@
if (method === 'ui/notifications/sandbox-resource-ready') {
var params = data.params || {};
var html = params.html || '';
var permissions = params.permissions || null;
createGuestIframe(html);
createGuestIframe(html, permissions);
return;
}
@@ -132,4 +144,4 @@
})();
</script>
</body>
</html>
</html>
+3 -1
View File
@@ -4,4 +4,6 @@ pub mod resource;
pub use app::{fetch_mcp_apps, GooseApp, WindowProps};
pub use cache::McpAppCache;
pub use resource::{CspMetadata, McpAppResource, ResourceMetadata, UiMetadata};
pub use resource::{
CspMetadata, McpAppResource, PermissionsMetadata, ResourceMetadata, UiMetadata,
};
+28
View File
@@ -14,6 +14,30 @@ pub struct CspMetadata {
pub resource_domains: Option<Vec<String>>,
}
/// Sandbox permissions for MCP Apps
/// Specifies which browser capabilities the UI needs access to.
/// Maps to the iframe Permission Policy `allow` attribute.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub struct PermissionsMetadata {
/// Request camera access (maps to Permission Policy `camera` feature)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub camera: bool,
/// Request microphone access (maps to Permission Policy `microphone` feature)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub microphone: bool,
/// Request geolocation access (maps to Permission Policy `geolocation` feature)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub geolocation: bool,
/// Request clipboard write access (maps to Permission Policy `clipboard-write` feature)
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub clipboard_write: bool,
}
fn is_default_permissions(p: &PermissionsMetadata) -> bool {
*p == PermissionsMetadata::default()
}
/// UI-specific metadata for MCP resources
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
@@ -21,6 +45,9 @@ pub struct UiMetadata {
/// Content Security Policy configuration
#[serde(skip_serializing_if = "Option::is_none")]
pub csp: Option<CspMetadata>,
/// Sandbox permissions requested by the UI
#[serde(default, skip_serializing_if = "is_default_permissions")]
pub permissions: PermissionsMetadata,
/// Preferred domain for the app (used for CORS)
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
@@ -87,6 +114,7 @@ impl McpAppResource {
meta: Some(ResourceMetadata {
ui: Some(UiMetadata {
csp: Some(csp),
permissions: PermissionsMetadata::default(),
domain: None,
prefers_border: None,
}),
+25
View File
@@ -5249,6 +5249,28 @@
"never_allow"
]
},
"PermissionsMetadata": {
"type": "object",
"description": "Sandbox permissions for MCP Apps\nSpecifies which browser capabilities the UI needs access to.\nMaps to the iframe Permission Policy `allow` attribute.",
"properties": {
"camera": {
"type": "boolean",
"description": "Request camera access (maps to Permission Policy `camera` feature)"
},
"clipboardWrite": {
"type": "boolean",
"description": "Request clipboard write access (maps to Permission Policy `clipboard-write` feature)"
},
"geolocation": {
"type": "boolean",
"description": "Request geolocation access (maps to Permission Policy `geolocation` feature)"
},
"microphone": {
"type": "boolean",
"description": "Request microphone access (maps to Permission Policy `microphone` feature)"
}
}
},
"PricingData": {
"type": "object",
"required": [
@@ -7021,6 +7043,9 @@
"description": "Preferred domain for the app (used for CORS)",
"nullable": true
},
"permissions": {
"$ref": "#/components/schemas/PermissionsMetadata"
},
"prefersBorder": {
"type": "boolean",
"description": "Whether the app prefers to have a border around it",
File diff suppressed because one or more lines are too long
+25
View File
@@ -667,6 +667,30 @@ export type ParseRecipeResponse = {
*/
export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow';
/**
* Sandbox permissions for MCP Apps
* Specifies which browser capabilities the UI needs access to.
* Maps to the iframe Permission Policy `allow` attribute.
*/
export type PermissionsMetadata = {
/**
* Request camera access (maps to Permission Policy `camera` feature)
*/
camera?: boolean;
/**
* Request clipboard write access (maps to Permission Policy `clipboard-write` feature)
*/
clipboardWrite?: boolean;
/**
* Request geolocation access (maps to Permission Policy `geolocation` feature)
*/
geolocation?: boolean;
/**
* Request microphone access (maps to Permission Policy `microphone` feature)
*/
microphone?: boolean;
};
export type PricingData = {
context_length?: number | null;
currency: string;
@@ -1273,6 +1297,7 @@ export type UiMetadata = {
* Preferred domain for the app (used for CORS)
*/
domain?: string | null;
permissions?: PermissionsMetadata;
/**
* Whether the app prefers to have a border around it
*/
@@ -15,6 +15,7 @@ import {
ToolResult,
ToolCancelled,
CspMetadata,
PermissionsMetadata,
McpMethodParams,
McpMethodResponse,
} from './types';
@@ -40,6 +41,7 @@ interface McpAppRendererProps {
interface ResourceData {
html: string | null;
csp: CspMetadata | null;
permissions: PermissionsMetadata | null;
prefersBorder: boolean;
}
@@ -58,6 +60,7 @@ export default function McpAppRenderer({
const [resource, setResource] = useState<ResourceData>({
html: cachedHtml || null,
csp: null,
permissions: null,
prefersBorder: true,
});
const [error, setError] = useState<string | null>(null);
@@ -82,13 +85,14 @@ export default function McpAppRenderer({
if (response.data) {
const content = response.data;
const meta = content._meta as
| { ui?: { csp?: CspMetadata; prefersBorder?: boolean } }
| { ui?: { csp?: CspMetadata; permissions?: PermissionsMetadata; prefersBorder?: boolean } }
| undefined;
if (content.text !== cachedHtml) {
setResource({
html: content.text,
csp: meta?.ui?.csp || null,
permissions: meta?.ui?.permissions || null,
prefersBorder: meta?.ui?.prefersBorder ?? true,
});
}
@@ -241,6 +245,7 @@ export default function McpAppRenderer({
const { iframeRef, proxyUrl } = useSandboxBridge({
resourceHtml: resource.html || '',
resourceCsp: resource.csp,
resourcePermissions: resource.permissions,
resourceUri,
toolInput,
toolInputPartial,
+1 -1
View File
@@ -1,4 +1,4 @@
export type { CspMetadata, CallToolResponse as ToolResult } from '../../api/types.gen';
export type { CspMetadata, PermissionsMetadata, CallToolResponse as ToolResult } from '../../api/types.gen';
export type ContentBlock =
| { type: 'text'; text: string }
@@ -9,6 +9,7 @@ import type {
ToolCancelled,
HostContext,
CspMetadata,
PermissionsMetadata,
} from './types';
import { fetchMcpAppProxyUrl } from './utils';
import { useTheme } from '../../contexts/ThemeContext';
@@ -18,6 +19,7 @@ import { errorMessage } from '../../utils/conversionUtils';
interface SandboxBridgeOptions {
resourceHtml: string;
resourceCsp: CspMetadata | null;
resourcePermissions: PermissionsMetadata | null;
resourceUri: string;
toolInput?: ToolInput;
toolInputPartial?: ToolInputPartial;
@@ -40,6 +42,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
const {
resourceHtml,
resourceCsp,
resourcePermissions,
resourceUri,
toolInput,
toolInputPartial,
@@ -80,7 +83,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
sendToSandbox({
jsonrpc: '2.0',
method: 'ui/notifications/sandbox-resource-ready',
params: { html: resourceHtml, csp: resourceCsp },
params: { html: resourceHtml, csp: resourceCsp, permissions: resourcePermissions },
});
break;
@@ -181,6 +184,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
[
resourceHtml,
resourceCsp,
resourcePermissions,
resolvedTheme,
sendToSandbox,
onMcpRequest,