feat(dashboard): task description + summary render sanitized HTML + mermaid

Extract the markdown HTML/mermaid pipeline into shared markdownPipeline.tsx (sharedSanitizeSchema, sharedRehypePlugins, createMermaidCodeComponent). MailboxMessageContent now consumes it (unchanged behavior). TaskDetailModal's description + summary ReactMarkdown gain sharedRehypePlugins + the mermaid code component (merged with the existing file-path linkify), keeping the .markdown-body wrapper — so raw HTML renders, comments drop, scripts are sanitized, and mermaid blocks render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 11:15:46 -07:00
parent b4e4013869
commit 459237c974
4 changed files with 193 additions and 70 deletions

View File

@@ -1,52 +1,19 @@
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import type { Options as SanitizeSchema } from "rehype-sanitize";
import type { Components } from "react-markdown";
import type { PluggableList } from "unified";
import { linkifyReactChildren } from "../utils/filePathLinkify";
import { MermaidDiagram } from "./MermaidDiagram";
import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline";
/*
FNXC:Markdown 2026-06-23-03:15:
GitHub PR/issue bodies + comments (and mailbox/chat) embed raw HTML (`<details>`,
`<summary>`, `<kbd>`, `<sub>`, tables), HTML comments (`<!-- -->`), and ```mermaid
blocks. Previously raw HTML was escaped to literal text and mermaid showed as code.
Pipeline (ORDER MATTERS): remark-gfm -> rehype-raw -> rehype-sanitize.
- rehype-raw parses embedded HTML into the hast tree so it renders as real elements.
It also DROPS HTML comments by default, so `<!-- ... -->` never appears in output.
- rehype-sanitize runs AFTER raw to strip XSS: <script>/<style>/<iframe>, event
handlers (onClick etc.), and javascript: URLs. Because these bodies come from
GitHub (untrusted), sanitize is mandatory — raw without sanitize would be an XSS
hole. Running sanitize last guarantees nothing injected via raw survives.
FNXC:Markdown 2026-06-23-03:30:
The sanitize schema, rehype plugin chain (rehype-raw -> rehype-sanitize), and the
mermaid-aware code component now live in ./markdownPipeline so the task
description + summary in TaskDetailModal share the exact same XSS posture. This
component consumes those shared exports; see markdownPipeline.tsx for the rationale.
*/
/*
FNXC:Markdown 2026-06-23-03:15:
Sanitize schema = rehype-sanitize defaultSchema (a conservative GitHub-like allow
list that already permits details/summary/kbd/sub/sup/b/i/em/strong/a/img/code/pre/
tables/br/hr/blockquote/lists/headings/span/div and strips script/style/event
handlers/javascript: URLs) EXTENDED to ensure the `className` attribute survives on
common elements (needed for our `language-*` code fences and styled wrappers). We do
NOT widen tagNames beyond defaults, so script/style/iframe stay stripped.
*/
const mailboxSanitizeSchema: SanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// Preserve className on code/span/div/pre so language fences + wrapper styling work.
code: [...(defaultSchema.attributes?.code ?? []), "className"],
span: [...(defaultSchema.attributes?.span ?? []), "className"],
div: [...(defaultSchema.attributes?.div ?? []), "className"],
pre: [...(defaultSchema.attributes?.pre ?? []), "className"],
// `<details open>` disclosure state should round-trip.
details: [...(defaultSchema.attributes?.details ?? []), "open"],
},
};
const mailboxMarkdownComponents: Components = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
@@ -60,24 +27,9 @@ const mailboxMarkdownComponents: Components = {
{children}
</table>
),
/*
FNXC:Markdown 2026-06-23-03:15:
Code-block override: a fenced ```mermaid block arrives as `<code class="language-mermaid">`.
Render it via <MermaidDiagram>, which lazy-imports mermaid so the heavy library is
only pulled in when a diagram is present. All other code (inline + other languages)
keeps the default rendering.
*/
code: ({ className, children, ...props }) => {
if (className === "language-mermaid") {
const chart = String(children ?? "").replace(/\n$/, "");
return <MermaidDiagram chart={chart} testId="mailbox-mermaid-diagram" />;
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
// Code-block override: fenced ```mermaid renders as a diagram; all other code
// keeps default rendering. See createMermaidCodeComponent in markdownPipeline.
code: createMermaidCodeComponent("mailbox-mermaid-diagram"),
// Open links in a new tab. Sanitize strips javascript: URLs and event handlers
// before this runs, so href is safe.
a: ({ children, ...props }) => (
@@ -88,8 +40,6 @@ const mailboxMarkdownComponents: Components = {
};
const remarkPlugins: PluggableList = [remarkGfm];
// Raw must run before sanitize: parse HTML, then strip anything unsafe.
const rehypePlugins: PluggableList = [rehypeRaw, [rehypeSanitize, mailboxSanitizeSchema]];
interface MailboxMessageContentProps {
/** Raw message body. Rendered as GitHub-flavored markdown. */
@@ -123,7 +73,7 @@ export const MailboxMessageContent = memo(function MailboxMessageContent({
<div className={wrapperClass} data-testid={testId}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
rehypePlugins={sharedRehypePlugins}
components={mailboxMarkdownComponents}
>
{content}

View File

@@ -9,6 +9,7 @@ import { useColumnLabel } from "../i18n/labels";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { sharedRehypePlugins, createMermaidCodeComponent } from "./markdownPipeline";
import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core";
import {
DEFAULT_TASK_PRIORITY,
@@ -79,17 +80,30 @@ function isStringValue(value: unknown): value is string {
return Object.prototype.toString.call(value) === STRING_OBJECT_TAG;
}
/*
FNXC:Markdown 2026-06-23-03:30:
The task DESCRIPTION (spec/prompt) + SUMMARY render via these components plus the
shared rehype chain (sharedRehypePlugins) so they gain sanitized raw HTML
(`<details>`/tables/`<kbd>`), drop HTML comments, and render ```mermaid diagrams —
matching the shared markdown renderer. They KEEP their `.markdown-body` styling
(NOT the `.mailbox-markdown` wrapper), so the look is unchanged for normal markdown.
The file-path linkify `code` renderer is preserved as the fallback for non-mermaid
code, so links AND html AND mermaid all work together.
*/
const markdownLinkifyCodeComponent: NonNullable<Components["code"]> = ({ children, ...props }) => {
const text = React.Children.toArray(children).join(EMPTY_MARKDOWN_CHILD_SEPARATOR);
const linkedChildren = linkifyFilePaths(text);
if (linkedChildren.length === 1 && linkedChildren[0]?.constructor === String) {
return <code {...props}>{children}</code>;
}
return <code {...props}>{linkedChildren}</code>;
};
const markdownLinkifyComponents: Components = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
code: ({ children, ...props }) => {
const text = React.Children.toArray(children).join(EMPTY_MARKDOWN_CHILD_SEPARATOR);
const linkedChildren = linkifyFilePaths(text);
if (linkedChildren.length === 1 && linkedChildren[0]?.constructor === String) {
return <code {...props}>{children}</code>;
}
return <code {...props}>{linkedChildren}</code>;
},
// Mermaid fences render as diagrams; all other code falls through to file-path linkify.
code: createMermaidCodeComponent("task-detail-mermaid-diagram", markdownLinkifyCodeComponent),
};
/**
@@ -3565,7 +3579,7 @@ export function TaskDetailContent({
<div className="detail-section detail-summary">
<h4>{t("taskDetail.summary.heading", "Summary")}</h4>
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins} components={markdownLinkifyComponents}>
{task.summary}
</ReactMarkdown>
</div>
@@ -3833,7 +3847,7 @@ export function TaskDetailContent({
<div className="spec-loading"><LoadingSpinner label={t("taskDetail.spec.loading", "Loading specification…")} /></div>
) : workingTask.prompt ? (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownLinkifyComponents}>
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={sharedRehypePlugins} components={markdownLinkifyComponents}>
{workingTask.prompt.replace(/^#\s+[^\n]*\n+/, "")}
</ReactMarkdown>
</div>

View File

@@ -4,6 +4,15 @@ FN-6532 made Chat the default TaskDetailModal tab. Tests that assert Definition-
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
// FNXC:Markdown 2026-06-23-03:30: Mock the heavy `mermaid` library so the shared
// markdown pipeline's MermaidDiagram resolves without loading the real renderer.
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue({ svg: "<svg data-testid='mock-mermaid-svg'></svg>" }),
},
}));
import userEvent from "@testing-library/user-event";
import {
makeTask,
@@ -57,6 +66,61 @@ describe("TaskDetailModal", () => {
expect(openFile).toHaveBeenCalledWith("packages/dashboard/app/App.tsx", { line: 12, col: undefined });
});
/*
FNXC:Markdown 2026-06-23-03:30:
The task DESCRIPTION (spec/prompt) + SUMMARY now share the markdown pipeline's
rehype-raw -> rehype-sanitize chain, so embedded raw HTML renders as real
elements (not literal text), HTML comments drop, <script> is stripped, and
```mermaid fences render diagrams — while keeping `.markdown-body` styling.
*/
it("renders raw HTML and mermaid in the description while stripping unsafe content", async () => {
const prompt = [
"# Prompt",
"",
"<details><summary>Disclosure title</summary>Hidden detail body.</details>",
"",
"<!-- secret comment -->",
"",
"<script>window.__pwned = true;</script>",
"",
"```mermaid",
"graph TD; A-->B;",
"```",
].join("\n");
const { container } = render(
<FileBrowserProvider openFile={vi.fn()}>
<TaskDetailModal
initialTab="definition"
task={makeTask({ prompt })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>
</FileBrowserProvider>,
);
// Raw <details>/<summary> renders as a real disclosure element.
const details = container.querySelector(".markdown-body details");
expect(details).not.toBeNull();
expect(details?.querySelector("summary")?.textContent).toBe("Disclosure title");
expect(details?.textContent).toContain("Hidden detail body.");
// HTML comment is dropped, never shown as literal text.
expect(container.textContent).not.toContain("secret comment");
// <script> is stripped by sanitize: not rendered and never executed.
expect(container.querySelector("script")).toBeNull();
expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
// ```mermaid fence renders the diagram container (lazy MermaidDiagram).
const diagram = await screen.findByTestId("task-detail-mermaid-diagram");
expect(diagram).not.toBeNull();
});
describe("provenance display", () => {
it.each([
["dashboard_ui", undefined, "Created via Dashboard"],

View File

@@ -0,0 +1,95 @@
import type { ReactElement } from "react";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import type { Options as SanitizeSchema } from "rehype-sanitize";
import type { Components } from "react-markdown";
import type { PluggableList } from "unified";
import { MermaidDiagram } from "./MermaidDiagram";
/*
FNXC:Markdown 2026-06-23-03:30:
Shared markdown rendering pipeline. Both mailbox/chat bodies and the task
DESCRIPTION (spec/prompt) + SUMMARY in TaskDetailModal need to render embedded
raw HTML (`<details>`, `<summary>`, `<kbd>`, `<sub>`, tables), drop HTML comments
(`<!-- -->`), and render ```mermaid blocks as diagrams. The sanitize schema +
rehype plugin chain are defined ONCE here so every renderer shares the exact same
XSS posture instead of duplicating (and drifting on) the allow list.
Pipeline (ORDER MATTERS): remark-gfm (added by the caller) -> rehype-raw -> rehype-sanitize.
- rehype-raw parses embedded HTML into the hast tree so it renders as real elements.
It also DROPS HTML comments by default, so `<!-- ... -->` never appears in output.
- rehype-sanitize runs AFTER raw to strip XSS: <script>/<style>/<iframe>, event
handlers (onClick etc.), and javascript: URLs. Because these bodies can come from
GitHub (untrusted), sanitize is mandatory — raw without sanitize would be an XSS
hole. Running sanitize last guarantees nothing injected via raw survives.
*/
/*
FNXC:Markdown 2026-06-23-03:30:
Sanitize schema = rehype-sanitize defaultSchema (a conservative GitHub-like allow
list that already permits details/summary/kbd/sub/sup/b/i/em/strong/a/img/code/pre/
tables/br/hr/blockquote/lists/headings/span/div and strips script/style/event
handlers/javascript: URLs) EXTENDED to ensure the `className` attribute survives on
common elements (needed for our `language-*` code fences and styled wrappers). We do
NOT widen tagNames beyond defaults, so script/style/iframe stay stripped.
*/
export const sharedSanitizeSchema: SanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// Preserve className on code/span/div/pre so language fences + wrapper styling work.
code: [...(defaultSchema.attributes?.code ?? []), "className"],
span: [...(defaultSchema.attributes?.span ?? []), "className"],
div: [...(defaultSchema.attributes?.div ?? []), "className"],
pre: [...(defaultSchema.attributes?.pre ?? []), "className"],
// `<details open>` disclosure state should round-trip.
details: [...(defaultSchema.attributes?.details ?? []), "open"],
},
};
/**
* Shared rehype plugin chain enabling sanitized raw HTML.
*
* Raw must run before sanitize: parse HTML, then strip anything unsafe.
* Pass this as `rehypePlugins` to any ReactMarkdown instance that should render
* embedded HTML. The caller supplies `remarkPlugins` (typically `[remarkGfm]`).
*/
export const sharedRehypePlugins: PluggableList = [
rehypeRaw,
[rehypeSanitize, sharedSanitizeSchema],
];
/**
* Factory for a mermaid-aware `code` component.
*
* FNXC:Markdown 2026-06-23-03:30:
* A fenced ```mermaid block arrives as `<code class="language-mermaid">`. Render
* it via <MermaidDiagram>, which lazy-imports mermaid so the heavy library is only
* pulled in when a diagram is present. All other code (inline + other languages)
* falls through to `fallback` (the caller's existing `code` renderer, e.g. file-path
* linkify) or default rendering when no fallback is given.
*
* @param testId data-testid for the rendered diagram (distinct per surface).
* @param fallback the caller's `code` component for non-mermaid code.
*/
export function createMermaidCodeComponent(
testId: string,
fallback?: Components["code"],
): NonNullable<Components["code"]> {
return function MermaidAwareCode(props) {
const { className, children } = props;
if (className === "language-mermaid") {
const chart = String(children ?? "").replace(/\n$/, "");
return <MermaidDiagram chart={chart} testId={testId} />;
}
if (fallback) {
const Fallback = fallback as (p: typeof props) => ReactElement;
return <Fallback {...props} />;
}
return (
<code className={className} {...props}>
{children}
</code>
);
};
}