Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 15 additions & 3 deletions crates/goose-server/src/routes/templates/mcp_app_proxy.html
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

let guestIframe = null;

function createGuestIframe(html) {
function createGuestIframe(html, permissions) {
if (guestIframe) {
guestIframe.remove();
}
Expand All @@ -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');
Comment on lines +52 to +55
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permissions checks rely on truthiness (e.g. permissions.camera), so a malformed metadata value like 'true' would unintentionally enable the feature; please validate each permission is exactly true before adding it to the allow list.

Suggested change
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 (permissions && permissions.camera === true) allowList.push('camera');
if (permissions && permissions.microphone === true) allowList.push('microphone');
if (permissions && permissions.geolocation === true) allowList.push('geolocation');
if (permissions && permissions.clipboardWrite === true) allowList.push('clipboard-write');

Copilot uses AI. Check for mistakes.
if (allowList.length > 0) {
guestIframe.setAttribute('allow', allowList.join('; '));
}

guestIframe.srcdoc = html;
guestIframe.style.cssText = 'width:100%; height:100%; border:none;';

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -132,4 +144,4 @@
})();
</script>
</body>
</html>
</html>
4 changes: 3 additions & 1 deletion crates/goose/src/goose_apps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 28 additions & 0 deletions crates/goose/src/goose_apps/resource.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,40 @@ 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")]
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>,
Expand Down Expand Up @@ -87,6 +114,7 @@ impl McpAppResource {
meta: Some(ResourceMetadata {
ui: Some(UiMetadata {
csp: Some(csp),
permissions: PermissionsMetadata::default(),
domain: None,
prefers_border: None,
}),
Expand Down
25 changes: 25 additions & 0 deletions ui/desktop/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion ui/desktop/src/api/index.ts

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions ui/desktop/src/api/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand Down
7 changes: 6 additions & 1 deletion ui/desktop/src/components/McpApps/McpAppRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
ToolResult,
ToolCancelled,
CspMetadata,
PermissionsMetadata,
McpMethodParams,
McpMethodResponse,
} from './types';
Expand All @@ -40,6 +41,7 @@ interface McpAppRendererProps {
interface ResourceData {
html: string | null;
csp: CspMetadata | null;
permissions: PermissionsMetadata | null;
prefersBorder: boolean;
}

Expand All @@ -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);
Expand All @@ -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,
});
}
Comment on lines 91 to 98
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guarding the state update on content.text !== cachedHtml prevents applying updated UI metadata (CSP/permissions/prefersBorder) when cached HTML matches the fetched HTML (e.g. StandaloneAppView keeps cachedHtml set even after session init); update the metadata even when the HTML is unchanged.

Suggested change
if (content.text !== cachedHtml) {
setResource({
html: content.text,
csp: meta?.ui?.csp || null,
permissions: meta?.ui?.permissions || null,
prefersBorder: meta?.ui?.prefersBorder ?? true,
});
}
setResource({
html: content.text,
csp: meta?.ui?.csp || null,
permissions: meta?.ui?.permissions || null,
prefersBorder: meta?.ui?.prefersBorder ?? true,
});

Copilot uses AI. Check for mistakes.
Expand Down Expand Up @@ -241,6 +245,7 @@ export default function McpAppRenderer({
const { iframeRef, proxyUrl } = useSandboxBridge({
resourceHtml: resource.html || '',
resourceCsp: resource.csp,
resourcePermissions: resource.permissions,
resourceUri,
Comment on lines 245 to 249
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

permissions is passed into the sandbox bridge, but the outer proxy iframe rendered by this component never sets an allow attribute; Permission Policy is intersected across ancestor iframes, so camera/mic/geolocation/clipboard-write will likely stay blocked when the proxy URL is cross-origin (e.g., goosed baseUrl). Consider also setting allow on the React iframe based on resource.permissions.

Copilot uses AI. Check for mistakes.
toolInput,
toolInputPartial,
Expand Down
2 changes: 1 addition & 1 deletion ui/desktop/src/components/McpApps/types.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand Down
6 changes: 5 additions & 1 deletion ui/desktop/src/components/McpApps/useSandboxBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
ToolCancelled,
HostContext,
CspMetadata,
PermissionsMetadata,
} from './types';
import { fetchMcpAppProxyUrl } from './utils';
import { useTheme } from '../../contexts/ThemeContext';
Expand All @@ -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;
Expand All @@ -40,6 +42,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
const {
resourceHtml,
resourceCsp,
resourcePermissions,
resourceUri,
toolInput,
toolInputPartial,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -181,6 +184,7 @@ export function useSandboxBridge(options: SandboxBridgeOptions): SandboxBridgeRe
[
resourceHtml,
resourceCsp,
resourcePermissions,
resolvedTheme,
sendToSandbox,
onMcpRequest,
Expand Down
Loading