diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx
index 3615c501a4..0e8572fbdc 100644
--- a/ui/src/components/MarkdownBody.test.tsx
+++ b/ui/src/components/MarkdownBody.test.tsx
@@ -120,6 +120,33 @@ describe("MarkdownBody", () => {
expect(html).toContain('alt="Org chart"');
});
+ it("renders decision images as inert references without resolving or preloading them", () => {
+ const resolveImageSrc = vi.fn((src: string) => `https://resolver.invalid/${src}`);
+ const html = renderMarkdown(
+ '# Plan\n\n**Keep this step.**\n\n\n\n\n\n',
+ [], { mediaMode: "reference", resolveImageSrc },
+ );
+
+ expect(html).toContain("
Plan
");
+ expect(html).toContain("Keep this step.");
+ expect(html).toContain("Image: Evidence (https://provider.invalid/track.png)");
+ expect(html).toContain('title="Provider image"');
+ expect(html).toContain("Image: Relative (images/proof.png)");
+ expect(html).toContain("Image: Protocol relative (//provider.invalid/other.png)");
+ expect(html).not.toMatch(/<(?:img|link|iframe|video|audio)\b/);
+ expect(resolveImageSrc).not.toHaveBeenCalled();
+ });
+
+ it("keeps diagram source inert in decision media mode", () => {
+ const source = 'flowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }';
+ const html = renderMarkdown(`\`\`\`mermaid\n${source}\n\`\`\``, [], { mediaMode: "reference" });
+
+ expect(html).toContain('class="language-mermaid"');
+ expect(html).toContain("https://provider.invalid/diagram.png");
+ expect(html).not.toContain('class="paperclip-mermaid"');
+ expect(html).not.toContain("Rendering Mermaid diagram");
+ });
+
it("renders user, agent, project, skill, and routine mentions as chips", () => {
const html = renderToStaticMarkup(
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index 98c06bf01f..24b9772854 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -90,6 +90,8 @@ interface MarkdownBodyProps {
resolveImageSrc?: (src: string) => string | null;
/** Called when a user clicks an inline image */
onImageClick?: (src: string) => void;
+ /** Keep untrusted decision media inert: image references and diagram source only. */
+ mediaMode?: "render" | "reference";
/**
* Resolver that decides which inline-code workspace file paths may be linked
* to the issue file viewer. Omitting it (or returning null) leaves every
@@ -719,6 +721,7 @@ function MarkdownBodyImpl({
externalReferences,
resolveImageSrc,
onImageClick,
+ mediaMode = "render",
resolveWorkspaceFileRef,
}: MarkdownBodyProps) {
const { theme } = useTheme();
@@ -803,7 +806,7 @@ function MarkdownBodyImpl({
),
pre: ({ node: _node, children: preChildren, ...preProps }) => {
const mermaidSource = extractMermaidSource(preChildren);
- if (mermaidSource) {
+ if (mermaidSource && mediaMode === "render") {
return ;
}
return {preChildren};
@@ -914,7 +917,13 @@ function MarkdownBodyImpl({
);
},
};
- if (resolveImageSrc || onImageClick) {
+ if (mediaMode === "reference") {
+ map.img = ({ src, alt, title }) => (
+
+ Image: {alt || "Untitled image"}{src ? ` (${src})` : ""}
+
+ );
+ } else if (resolveImageSrc || onImageClick) {
map.img = ({ node: _node, src, alt, ...imgProps }) => {
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
const finalSrc = resolved ?? src;
@@ -930,7 +939,7 @@ function MarkdownBodyImpl({
};
}
return map;
- }, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, onImageClick]);
+ }, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, onImageClick, mediaMode]);
return (
- {questionSet.description}
+ {questionSet.description}
) : null}
{question.answerMode === "text" ? (
diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx
index 9af1b59b91..00961fdbaa 100644
--- a/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx
+++ b/ui/src/components/task-chat/TaskChatProtocolCard.test.tsx
@@ -632,6 +632,35 @@ describe("TaskChatProtocolCard", () => {
expect(container.textContent).toContain("Submitting…");
});
+ it("keeps complete provider plan Markdown while making image references inert", () => {
+ const prefix = '# Release plan\n\n**Preserve every instruction.**\n\n\n\n\n\n';
+ const suffix = '\n\n```mermaid\nflowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }\n```\n\nFINAL_PLAN_BOUNDARY';
+ const fullInstructions = "x".repeat(100_000 - prefix.length - suffix.length);
+ const description = prefix + fullInstructions + suffix;
+ expect(description).toHaveLength(100_000);
+ renderCard(root, {
+ id: "provider-plan", kind: "protocol", surface: "runtime_request", runId: "run-1",
+ requestId: "plan-revision-7", requestKind: "runtime", turnId: "turn-1",
+ requestType: "input", status: "pending", prompt: "Review the complete plan.", choices: [], fields: [],
+ questionSet: {
+ schema: "paperclip.question_set.v1", description,
+ questions: [{ id: "decision", prompt: "Accept this plan?", required: true, answerMode: "single_select",
+ options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }] }],
+ },
+ });
+
+ const context = container.querySelector('[role="region"][aria-label="Question context"]');
+ expect(context?.querySelector("h1")?.textContent).toBe("Release plan");
+ expect(context?.querySelector("strong")?.textContent).toBe("Preserve every instruction.");
+ expect(context?.textContent).toContain(fullInstructions);
+ expect(context?.textContent).toContain("FINAL_PLAN_BOUNDARY");
+ expect(context?.textContent).toContain("Evidence");
+ expect(context?.textContent).toContain("https://provider.invalid/track.png");
+ expect(context?.querySelector("img, video, audio, iframe, object, embed, image, link")).toBeNull();
+ expect(context?.querySelector('.language-mermaid')?.textContent).toContain('https://provider.invalid/diagram.png');
+ expect(context?.querySelector('.paperclip-mermaid')).toBeNull();
+ });
+
it("submits the canonical response from a v2 harness question set", async () => {
const onDecision = vi.fn().mockResolvedValue(undefined);
renderCard(