Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
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
57 changes: 32 additions & 25 deletions apps/mobile/src/app/task/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,11 @@ export default function TaskDetailScreen() {
// creates a fresh run that resumes from the previous one and queues the
// message as pending_user_message.
const handleSendAfterTerminal = useCallback(
async (text: string, attachments: PendingAttachment[]) => {
if (!taskId || !task) return;
async (
text: string,
attachments: PendingAttachment[],
): Promise<boolean> => {
if (!taskId || !task) return false;
// Optimistically echo into the chat before tearing down the old session
// and waiting for the resume run's SSE stream to come up.
const echoAttachments = attachments.map((a) => ({
Expand Down Expand Up @@ -324,6 +327,7 @@ export default function TaskDetailScreen() {
setTask(updatedTask);
await connectToTask(updatedTask);
updateTaskInCache(updatedTask);
return true;
} catch (err) {
log.error("Failed to send after terminal", err);
pendingTaskPromptStoreApi.clear(taskId);
Expand All @@ -332,6 +336,7 @@ export default function TaskDetailScreen() {
"Failed to send",
"Could not continue this task. Please try again.",
);
return false;
}
},
[
Expand Down Expand Up @@ -361,8 +366,11 @@ export default function TaskDetailScreen() {
);

const handleSendPrompt = useCallback(
(text: string, attachments: PendingAttachment[]) => {
if (!taskId) return;
async (
text: string,
attachments: PendingAttachment[],
): Promise<boolean> => {
if (!taskId) return false;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

// Saving an in-place edit: overwrite the queued message and release the
Expand All @@ -374,38 +382,37 @@ export default function TaskDetailScreen() {
queue.update(taskId, editingId, { content: text, attachments });
queue.clearEditing(taskId);
flushQueuedMessagesIfIdle(taskId);
return;
return true;
}

if (session?.terminalStatus) {
handleSendAfterTerminal(text, attachments);
return;
return handleSendAfterTerminal(text, attachments);
}

const onSendFailed = (err: unknown) => {
// A turn is running. Queue holds the message locally until it ends;
// Steer interrupts the turn and resends right away.
const isSteer = !!session?.isPromptPending;
if (isSteer && messagingMode === "queue") {
useMessageQueueStore.getState().enqueue(taskId, text, attachments);
return true;
}

try {
if (isSteer) {
await sendInterrupting(taskId, text, attachments);
} else {
await sendPrompt(taskId, text, attachments);
}
trackPromptSent(text, isSteer);
return true;
} catch (err) {
log.error("Failed to send prompt", err);
Alert.alert(
"Failed to send",
"Your message could not be delivered. Please try again.",
);
};

// A turn is running. Queue holds the message locally until it ends;
// Steer interrupts the turn and resends right away.
if (session?.isPromptPending) {
if (messagingMode === "queue") {
useMessageQueueStore.getState().enqueue(taskId, text, attachments);
return;
}
sendInterrupting(taskId, text, attachments)
.then(() => trackPromptSent(text, true))
.catch(onSendFailed);
return;
return false;
}

sendPrompt(taskId, text, attachments)
.then(() => trackPromptSent(text, false))
.catch(onSendFailed);
},
[
taskId,
Expand Down
152 changes: 152 additions & 0 deletions apps/mobile/src/features/mcp/sandbox/mcpAppCsp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
applyCspToHtml,
buildCspMetaTag,
buildCspString,
escapeAttr,
sanitizeDomain,
} from "./mcpAppCsp";

describe("sanitizeDomain", () => {
it.each([
["example.com", "example.com"],
["*.example.com", "*.example.com"],
["example.com:8080", "example.com:8080"],
["' unsafe-eval; script-src *;", "unsafe-evalscript-src*"],
['" onload=alert(1)', "onloadalert1"],
["example.com; frame-ancestors *", "example.comframe-ancestors*"],
["example .com", "example.com"],
])("sanitizes %j", (input, expected) => {
expect(sanitizeDomain(input)).toBe(expected);
});
});

describe("buildCspString", () => {
it.each([
["default-src 'none'"],
["script-src 'self' 'unsafe-inline'"],
["style-src 'self' 'unsafe-inline'"],
["img-src 'self' data:"],
["media-src 'self' data:"],
["connect-src 'none'"],
["frame-src 'none'"],
["form-action 'none'"],
["base-uri 'none'"],
["object-src 'none'"],
])("default policy contains %s", (directive) => {
expect(buildCspString()).toContain(directive);
});

it.each([
["connect-src 'none'"],
["frame-src 'none'"],
["form-action 'none'"],
["base-uri 'none'"],
["img-src 'self' data:"],
])("uses the restrictive default %s for empty metadata", (directive) => {
expect(buildCspString({})).toContain(directive);
});

it("maps connectDomains to connect-src", () => {
expect(
buildCspString({
connectDomains: ["api.example.com", "*.cdn.example.com"],
}),
).toContain("connect-src api.example.com *.cdn.example.com");
});

it("maps resourceDomains to img/media/font/script/style-src", () => {
const result = buildCspString({ resourceDomains: ["cdn.example.com"] });
expect(result).toContain("img-src 'self' data: cdn.example.com");
expect(result).toContain("media-src 'self' data: cdn.example.com");
expect(result).toContain("font-src cdn.example.com");
expect(result).toContain(
"script-src 'self' 'unsafe-inline' cdn.example.com",
);
expect(result).toContain(
"style-src 'self' 'unsafe-inline' cdn.example.com",
);
});

it("omits resourceDomains from script/style-src when not declared", () => {
const result = buildCspString({});
expect(result).toContain("script-src 'self' 'unsafe-inline'");
expect(result).not.toMatch(/script-src 'self' 'unsafe-inline' ;/);
});

it("maps frameDomains to frame-src", () => {
expect(buildCspString({ frameDomains: ["embed.example.com"] })).toContain(
"frame-src embed.example.com",
);
});

it("maps baseUriDomains to base-uri", () => {
expect(buildCspString({ baseUriDomains: ["example.com"] })).toContain(
"base-uri example.com",
);
});

it("always includes form-action 'none'", () => {
expect(buildCspString({ connectDomains: ["api.example.com"] })).toContain(
"form-action 'none'",
);
});

it("sanitizes injection attempts in domains", () => {
const result = buildCspString({
connectDomains: ["example.com; script-src 'unsafe-eval'"],
});
expect(result).toContain("connect-src example.comscript-srcunsafe-eval");
expect(result).not.toMatch(/;\s*script-src\s+'unsafe-eval'/);
});
});

describe("escapeAttr", () => {
it.each([
['hello "world"', "hello &quot;world&quot;"],
["hello 'world'", "hello &#39;world&#39;"],
["a & b", "a &amp; b"],
["<script>alert(1)</script>", "&lt;script&gt;alert(1)&lt;/script&gt;"],
["default-src none", "default-src none"],
])("escapes %j", (input, expected) => {
expect(escapeAttr(input)).toBe(expected);
});
});

describe("buildCspMetaTag", () => {
it("returns a valid meta tag with the default policy", () => {
const tag = buildCspMetaTag();
expect(tag).toMatch(
/^<meta http-equiv="Content-Security-Policy" content=".*">$/,
);
expect(tag).toContain("default-src");
});

it("escapes the CSP content in the attribute", () => {
const tag = buildCspMetaTag({ connectDomains: ["example.com"] });
expect(tag).toContain("connect-src example.com");
expect(tag).toMatch(/content="[^"]+"/);
});
});

describe("applyCspToHtml", () => {
it("prepends the CSP meta when there is no doctype", () => {
const out = applyCspToHtml("<html><body>hi</body></html>");
expect(out.startsWith(buildCspMetaTag())).toBe(true);
});

it("inserts the CSP meta after a leading doctype", () => {
const out = applyCspToHtml("<!doctype html><html><head></head></html>");
expect(out).toBe(
`<!doctype html>${buildCspMetaTag()}<html><head></head></html>`,
);
});

it("keeps leading whitespace and mixed-case doctype before the meta", () => {
const out = applyCspToHtml(" <!DOCTYPE html>\n<html></html>");
expect(out.startsWith(" <!DOCTYPE html>")).toBe(true);
expect(out.indexOf("<!DOCTYPE html>")).toBeLessThan(
out.indexOf(buildCspMetaTag()),
);
});
});
85 changes: 85 additions & 0 deletions apps/mobile/src/features/mcp/sandbox/mcpAppCsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";

const DEFAULT_CSP =
"default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self' data:; connect-src 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'";

export function sanitizeDomain(domain: string): string {
return domain.replace(/[^a-zA-Z0-9.*:-]/g, "");
}

export function buildCspString(csp?: McpUiResourceCsp): string {
if (!csp) return DEFAULT_CSP;

const resourceDomains = csp.resourceDomains?.length
? csp.resourceDomains.map(sanitizeDomain).join(" ")
: "";
const resourceSuffix = resourceDomains ? ` ${resourceDomains}` : "";

const directives: string[] = [
"default-src 'none'",
`script-src 'self' 'unsafe-inline'${resourceSuffix}`,
`style-src 'self' 'unsafe-inline'${resourceSuffix}`,
"object-src 'none'",
"form-action 'none'",
];

if (csp.connectDomains?.length) {
directives.push(
`connect-src ${csp.connectDomains.map(sanitizeDomain).join(" ")}`,
);
} else {
directives.push("connect-src 'none'");
}

if (resourceDomains) {
directives.push(`img-src 'self' data: ${resourceDomains}`);
directives.push(`media-src 'self' data: ${resourceDomains}`);
directives.push(`font-src ${resourceDomains}`);
} else {
directives.push("img-src 'self' data:");
directives.push("media-src 'self' data:");
}

if (csp.frameDomains?.length) {
directives.push(
`frame-src ${csp.frameDomains.map(sanitizeDomain).join(" ")}`,
);
} else {
directives.push("frame-src 'none'");
}

if (csp.baseUriDomains?.length) {
directives.push(
`base-uri ${csp.baseUriDomains.map(sanitizeDomain).join(" ")}`,
);
} else {
directives.push("base-uri 'none'");
}

return directives.join("; ");
}

export function escapeAttr(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

export function buildCspMetaTag(csp?: McpUiResourceCsp): string {
return `<meta http-equiv="Content-Security-Policy" content="${escapeAttr(buildCspString(csp))}">`;
}

export function applyCspToHtml(html: string, csp?: McpUiResourceCsp): string {
const meta = buildCspMetaTag(csp);
// The doctype must stay first, or the frame drops into quirks mode.
const doctype = html.match(/^\s*<!doctype[^>]*>/i);
if (doctype) {
return (
html.slice(0, doctype[0].length) + meta + html.slice(doctype[0].length)
);
}
return meta + html;
}
3 changes: 2 additions & 1 deletion apps/mobile/src/features/mcp/sandbox/useMcpUiResource.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
getToolUiResourceUri,
type McpUiResourceCsp,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
Expand Down Expand Up @@ -67,7 +68,7 @@ export function useMcpUiResource({
const permissions =
(ui.permissions as Record<string, Record<string, unknown>>) ??
undefined;
const csp = (ui.csp as Record<string, unknown> | undefined) ?? undefined;
const csp = ui.csp as McpUiResourceCsp | undefined;

return {
resource: { uri, html: text, csp, permissions },
Expand Down
7 changes: 4 additions & 3 deletions apps/mobile/src/features/mcp/sandbox/useMobileAppBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type McpUiDisplayMode,
type McpUiHostCapabilities,
type McpUiHostContext,
type McpUiResourceCsp,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type {
CallToolResult,
Expand All @@ -15,6 +16,7 @@ import type { EdgeInsets } from "react-native-safe-area-context";
import type WebView from "react-native-webview";
import { logger } from "@/lib/logger";
import type { ThemeColors } from "@/lib/theme";
import { applyCspToHtml } from "./mcpAppCsp";
import { buildMcpHostStyles } from "./mcpAppTheme";
import { WebViewTransport } from "./webViewTransport";

Expand All @@ -30,8 +32,7 @@ export type Phase =
interface UiResource {
uri: string;
html: string;
/** Opaque `McpUiResourceCsp` shape — passed through to AppBridge unchanged. */
csp?: Record<string, unknown>;
csp?: McpUiResourceCsp;
permissions?: Record<string, Record<string, unknown>>;
}

Expand Down Expand Up @@ -256,7 +257,7 @@ export function useMobileAppBridge(
bridgeRef.current = bridge;

await bridge.sendSandboxResourceReady({
html: uiResource.html,
html: applyCspToHtml(uiResource.html, uiResource.csp),
csp: uiResource.csp,
permissions: uiResource.permissions,
});
Expand Down
Loading
Loading