feat(mcp-apps): add Permission Policy support for sandbox iframes (#6947)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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,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,
|
||||
|
||||
Reference in New Issue
Block a user