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::WindowProps,
|
||||||
goose::goose_apps::McpAppResource,
|
goose::goose_apps::McpAppResource,
|
||||||
goose::goose_apps::CspMetadata,
|
goose::goose_apps::CspMetadata,
|
||||||
|
goose::goose_apps::PermissionsMetadata,
|
||||||
goose::goose_apps::UiMetadata,
|
goose::goose_apps::UiMetadata,
|
||||||
goose::goose_apps::ResourceMetadata,
|
goose::goose_apps::ResourceMetadata,
|
||||||
super::routes::dictation::TranscribeRequest,
|
super::routes::dictation::TranscribeRequest,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
let guestIframe = null;
|
let guestIframe = null;
|
||||||
|
|
||||||
function createGuestIframe(html) {
|
function createGuestIframe(html, permissions) {
|
||||||
if (guestIframe) {
|
if (guestIframe) {
|
||||||
guestIframe.remove();
|
guestIframe.remove();
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,17 @@
|
|||||||
// allow-forms: needed if the app has forms
|
// allow-forms: needed if the app has forms
|
||||||
guestIframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-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.srcdoc = html;
|
||||||
guestIframe.style.cssText = 'width:100%; height:100%; border:none;';
|
guestIframe.style.cssText = 'width:100%; height:100%; border:none;';
|
||||||
|
|
||||||
@@ -73,8 +84,9 @@
|
|||||||
if (method === 'ui/notifications/sandbox-resource-ready') {
|
if (method === 'ui/notifications/sandbox-resource-ready') {
|
||||||
var params = data.params || {};
|
var params = data.params || {};
|
||||||
var html = params.html || '';
|
var html = params.html || '';
|
||||||
|
var permissions = params.permissions || null;
|
||||||
|
|
||||||
createGuestIframe(html);
|
createGuestIframe(html, permissions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,4 +144,4 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ pub mod resource;
|
|||||||
|
|
||||||
pub use app::{fetch_mcp_apps, GooseApp, WindowProps};
|
pub use app::{fetch_mcp_apps, GooseApp, WindowProps};
|
||||||
pub use cache::McpAppCache;
|
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>>,
|
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
|
/// UI-specific metadata for MCP resources
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -21,6 +45,9 @@ pub struct UiMetadata {
|
|||||||
/// Content Security Policy configuration
|
/// Content Security Policy configuration
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub csp: Option<CspMetadata>,
|
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)
|
/// Preferred domain for the app (used for CORS)
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub domain: Option<String>,
|
pub domain: Option<String>,
|
||||||
@@ -87,6 +114,7 @@ impl McpAppResource {
|
|||||||
meta: Some(ResourceMetadata {
|
meta: Some(ResourceMetadata {
|
||||||
ui: Some(UiMetadata {
|
ui: Some(UiMetadata {
|
||||||
csp: Some(csp),
|
csp: Some(csp),
|
||||||
|
permissions: PermissionsMetadata::default(),
|
||||||
domain: None,
|
domain: None,
|
||||||
prefers_border: None,
|
prefers_border: None,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5249,6 +5249,28 @@
|
|||||||
"never_allow"
|
"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": {
|
"PricingData": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -7021,6 +7043,9 @@
|
|||||||
"description": "Preferred domain for the app (used for CORS)",
|
"description": "Preferred domain for the app (used for CORS)",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"permissions": {
|
||||||
|
"$ref": "#/components/schemas/PermissionsMetadata"
|
||||||
|
},
|
||||||
"prefersBorder": {
|
"prefersBorder": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether the app prefers to have a border around it",
|
"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';
|
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 = {
|
export type PricingData = {
|
||||||
context_length?: number | null;
|
context_length?: number | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -1273,6 +1297,7 @@ export type UiMetadata = {
|
|||||||
* Preferred domain for the app (used for CORS)
|
* Preferred domain for the app (used for CORS)
|
||||||
*/
|
*/
|
||||||
domain?: string | null;
|
domain?: string | null;
|
||||||
|
permissions?: PermissionsMetadata;
|
||||||
/**
|
/**
|
||||||
* Whether the app prefers to have a border around it
|
* Whether the app prefers to have a border around it
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
ToolResult,
|
ToolResult,
|
||||||
ToolCancelled,
|
ToolCancelled,
|
||||||
CspMetadata,
|
CspMetadata,
|
||||||
|
PermissionsMetadata,
|
||||||
McpMethodParams,
|
McpMethodParams,
|
||||||
McpMethodResponse,
|
McpMethodResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -40,6 +41,7 @@ interface McpAppRendererProps {
|
|||||||
interface ResourceData {
|
interface ResourceData {
|
||||||
html: string | null;
|
html: string | null;
|
||||||
csp: CspMetadata | null;
|
csp: CspMetadata | null;
|
||||||
|
permissions: PermissionsMetadata | null;
|
||||||
prefersBorder: boolean;
|
prefersBorder: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ export default function McpAppRenderer({
|
|||||||
const [resource, setResource] = useState<ResourceData>({
|
const [resource, setResource] = useState<ResourceData>({
|
||||||
html: cachedHtml || null,
|
html: cachedHtml || null,
|
||||||
csp: null,
|
csp: null,
|
||||||
|
permissions: null,
|
||||||
prefersBorder: true,
|
prefersBorder: true,
|
||||||
});
|
});
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -82,13 +85,14 @@ export default function McpAppRenderer({
|
|||||||
if (response.data) {
|
if (response.data) {
|
||||||
const content = response.data;
|
const content = response.data;
|
||||||
const meta = content._meta as
|
const meta = content._meta as
|
||||||
| { ui?: { csp?: CspMetadata; prefersBorder?: boolean } }
|
| { ui?: { csp?: CspMetadata; permissions?: PermissionsMetadata; prefersBorder?: boolean } }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
if (content.text !== cachedHtml) {
|
if (content.text !== cachedHtml) {
|
||||||
setResource({
|
setResource({
|
||||||
html: content.text,
|
html: content.text,
|
||||||
csp: meta?.ui?.csp || null,
|
csp: meta?.ui?.csp || null,
|
||||||
|
permissions: meta?.ui?.permissions || null,
|
||||||
prefersBorder: meta?.ui?.prefersBorder ?? true,
|
prefersBorder: meta?.ui?.prefersBorder ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -241,6 +245,7 @@ export default function McpAppRenderer({
|
|||||||
const { iframeRef, proxyUrl } = useSandboxBridge({
|
const { iframeRef, proxyUrl } = useSandboxBridge({
|
||||||
resourceHtml: resource.html || '',
|
resourceHtml: resource.html || '',
|
||||||
resourceCsp: resource.csp,
|
resourceCsp: resource.csp,
|
||||||
|
resourcePermissions: resource.permissions,
|
||||||
resourceUri,
|
resourceUri,
|
||||||
toolInput,
|
toolInput,
|
||||||
toolInputPartial,
|
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 =
|
export type ContentBlock =
|
||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
ToolCancelled,
|
ToolCancelled,
|
||||||
HostContext,
|
HostContext,
|
||||||
CspMetadata,
|
CspMetadata,
|
||||||
|
PermissionsMetadata,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { fetchMcpAppProxyUrl } from './utils';
|
import { fetchMcpAppProxyUrl } from './utils';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
@@ -18,6 +19,7 @@ import { errorMessage } from '../../utils/conversionUtils';
|
|||||||
interface SandboxBridgeOptions {
|
interface SandboxBridgeOptions {
|
||||||
resourceHtml: string;
|
resourceHtml: string;
|
||||||
resourceCsp: CspMetadata | null;
|
resourceCsp: CspMetadata | null;
|
||||||
|
resourcePermissions: PermissionsMetadata | null;
|
||||||
resourceUri: string;
|
resourceUri: string;
|
||||||
toolInput?: ToolInput;
|
toolInput?: ToolInput;
|
||||||
toolInputPartial?: ToolInputPartial;
|
toolInputPartial?: ToolInputPartial;
|
||||||
@@ -40,6 +42,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
|||||||
const {
|
const {
|
||||||
resourceHtml,
|
resourceHtml,
|
||||||
resourceCsp,
|
resourceCsp,
|
||||||
|
resourcePermissions,
|
||||||
resourceUri,
|
resourceUri,
|
||||||
toolInput,
|
toolInput,
|
||||||
toolInputPartial,
|
toolInputPartial,
|
||||||
@@ -80,7 +83,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
|||||||
sendToSandbox({
|
sendToSandbox({
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
method: 'ui/notifications/sandbox-resource-ready',
|
method: 'ui/notifications/sandbox-resource-ready',
|
||||||
params: { html: resourceHtml, csp: resourceCsp },
|
params: { html: resourceHtml, csp: resourceCsp, permissions: resourcePermissions },
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -181,6 +184,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
|
|||||||
[
|
[
|
||||||
resourceHtml,
|
resourceHtml,
|
||||||
resourceCsp,
|
resourceCsp,
|
||||||
|
resourcePermissions,
|
||||||
resolvedTheme,
|
resolvedTheme,
|
||||||
sendToSandbox,
|
sendToSandbox,
|
||||||
onMcpRequest,
|
onMcpRequest,
|
||||||
|
|||||||
Reference in New Issue
Block a user