Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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

Copilot AI Feb 4, 2026

Copy link

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,
};
24 changes: 24 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,36 @@ 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(skip_serializing_if = "Option::is_none")]
pub camera: Option<bool>,
/// Request microphone access (maps to Permission Policy `microphone` feature)
#[serde(skip_serializing_if = "Option::is_none")]
pub microphone: Option<bool>,
/// Request geolocation access (maps to Permission Policy `geolocation` feature)
#[serde(skip_serializing_if = "Option::is_none")]
pub geolocation: Option<bool>,
/// Request clipboard write access (maps to Permission Policy `clipboard-write` feature)
#[serde(skip_serializing_if = "Option::is_none")]
pub clipboard_write: Option<bool>,
}

/// 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(skip_serializing_if = "Option::is_none")]
pub permissions: Option<PermissionsMetadata>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can we make this not Option<> and just provide a default implementation? same as for the booleans above. if we just make them non option booelan and initialize them as false, the generated code becomes cleaner and the client easier to read

/// 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 +110,7 @@ impl McpAppResource {
meta: Some(ResourceMetadata {
ui: Some(UiMetadata {
csp: Some(csp),
permissions: None,
domain: None,
prefers_border: None,
}),
Expand Down
34 changes: 34 additions & 0 deletions ui/desktop/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -5249,6 +5249,32 @@
"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)",
"nullable": true
},
"clipboardWrite": {
"type": "boolean",
"description": "Request clipboard write access (maps to Permission Policy `clipboard-write` feature)",
"nullable": true
},
"geolocation": {
"type": "boolean",
"description": "Request geolocation access (maps to Permission Policy `geolocation` feature)",
"nullable": true
},
"microphone": {
"type": "boolean",
"description": "Request microphone access (maps to Permission Policy `microphone` feature)",
"nullable": true
}
}
},
"PricingData": {
"type": "object",
"required": [
Expand Down Expand Up @@ -7021,6 +7047,14 @@
"description": "Preferred domain for the app (used for CORS)",
"nullable": true
},
"permissions": {
"allOf": [
{
"$ref": "#/components/schemas/PermissionsMetadata"
}
],
"nullable": true
},
"prefersBorder": {
"type": "boolean",
"description": "Whether the app prefers to have a border around it",
Expand Down
81 changes: 32 additions & 49 deletions ui/desktop/src/api/client/client.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,7 @@
import { createSseClient } from '../core/serverSentEvents.gen';
import type { HttpMethod } from '../core/types.gen';
import { getValidRequestBody } from '../core/utils.gen';
import type {
Client,
Config,
RequestOptions,
ResolvedRequestOptions,
} from './types.gen';
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from './types.gen';
import {
buildUrl,
createConfig,
Expand All @@ -34,12 +29,7 @@ export const createClient = (config: Config = {}): Client => {
return getConfig();
};

const interceptors = createInterceptors<
Request,
Response,
unknown,
ResolvedRequestOptions
>();
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>();

const beforeRequest = async (options: RequestOptions) => {
const opts = {
Expand Down Expand Up @@ -105,12 +95,7 @@ export const createClient = (config: Config = {}): Client => {

for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(
error,
undefined as any,
request,
opts,
)) as unknown;
finalError = (await fn(error, undefined as any, request, opts)) as unknown;
}
}

Expand Down Expand Up @@ -147,10 +132,7 @@ export const createClient = (config: Config = {}): Client => {
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';

if (
response.status === 204 ||
response.headers.get('Content-Length') === '0'
) {
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
let emptyData: any;
switch (parseAs) {
case 'arrayBuffer':
Expand Down Expand Up @@ -182,10 +164,16 @@ export const createClient = (config: Config = {}): Client => {
case 'arrayBuffer':
case 'blob':
case 'formData':
case 'json':
case 'text':
data = await response[parseAs]();
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
Expand Down Expand Up @@ -246,34 +234,29 @@ export const createClient = (config: Config = {}): Client => {
};
};

const makeMethodFn =
(method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
request({ ...options, method });

const makeSseFn =
(method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
const makeSseFn = (method: Uppercase<HttpMethod>) => async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options);
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
onRequest: async (url, init) => {
let request = new Request(url, init);
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
return request;
},
serializedBody: getValidRequestBody(opts) as
| BodyInit
| null
| undefined,
url,
});
};
}
return request;
},
serializedBody: getValidRequestBody(opts) as BodyInit | null | undefined,
url,
});
};

return {
buildUrl,
Expand Down
52 changes: 12 additions & 40 deletions ui/desktop/src/api/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,13 @@ import type {
ServerSentEventsOptions,
ServerSentEventsResult,
} from '../core/serverSentEvents.gen';
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types.gen';
import type { Client as CoreClient, Config as CoreConfig } from '../core/types.gen';
import type { Middleware } from './utils.gen';

export type ResponseStyle = 'data' | 'fields';

export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<RequestInit, 'body' | 'headers' | 'method'>,
CoreConfig {
extends Omit<RequestInit, 'body' | 'headers' | 'method'>, CoreConfig {
/**
* Base URL for all requests made by this client.
*/
Expand All @@ -42,14 +38,7 @@ export interface Config<T extends ClientOptions = ClientOptions>
*
* @default 'auto'
*/
parseAs?:
| 'arrayBuffer'
| 'auto'
| 'blob'
| 'formData'
| 'json'
| 'stream'
| 'text';
parseAs?: 'arrayBuffer' | 'auto' | 'blob' | 'formData' | 'json' | 'stream' | 'text';
/**
* Should we return only data or multiple fields (data, error, response, etc.)?
*
Expand All @@ -69,7 +58,9 @@ export interface RequestOptions<
TResponseStyle extends ResponseStyle = 'fields',
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
>
extends
Config<{
responseStyle: TResponseStyle;
throwOnError: ThrowOnError;
}>,
Expand Down Expand Up @@ -116,32 +107,22 @@ export type RequestResult<
? TData[keyof TData]
: TData
: {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
request: Request;
response: Response;
}
>
: Promise<
TResponseStyle extends 'data'
?
| (TData extends Record<string, unknown>
? TData[keyof TData]
: TData)
| undefined
? (TData extends Record<string, unknown> ? TData[keyof TData] : TData) | undefined
: (
| {
data: TData extends Record<string, unknown>
? TData[keyof TData]
: TData;
data: TData extends Record<string, unknown> ? TData[keyof TData] : TData;
error: undefined;
}
| {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
}
) & {
request: Request;
Expand Down Expand Up @@ -180,10 +161,7 @@ type RequestFn = <
TResponseStyle extends ResponseStyle = 'fields',
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, 'method'> &
Pick<
Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>,
'method'
>,
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>;

type BuildUrlFn = <
Expand All @@ -197,13 +175,7 @@ type BuildUrlFn = <
options: TData & Options<TData>,
) => string;

export type Client = CoreClient<
RequestFn,
Config,
MethodFn,
BuildUrlFn,
SseFn
> & {
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn, SseFn> & {
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>;
};

Expand Down
Loading
Loading