A `table` block's cells were plain text, so `**Deploy**`, `` `done` `` and
`[runbook](https://example.com)` — what agents routinely put in status and
reference columns — showed their literal characters. The same canvas rendered a
GFM pipe table written in `markdown` prose correctly, so an agent saw two tables
behaving differently, and the one the MCP tool guide recommends was the broken
one. Both structured paths are fixed at once: `CanvasBlock` delegates `table` to
`ReportRenderer`, and a ```table fence inside a `markdown` block delegates to the
same component.
Cells render INLINE markdown only. `marked.parseInline` never emits a block
element, and the cell policy drops the ones raw HTML could smuggle in, so a
heading or a list in a cell degrades to its own text (DOMPurify's `KEEP_CONTENT`)
rather than breaking the row the #2583 gallery pins.
The split is about testability, not taste. `markdown.js` cannot be imported
without a DOM — DOMPurify's DOM-less stub has no `addHook` — and vitest runs
`environment: 'node'`, so anything decided inside it is unreachable by a unit
test. `utils/inlineMarkdown.js` holds the decidable half (the parse, the
markdown-vs-text decision, the escape, the allowlist) and is executed by
`inlineMarkdown.spec.js`; `renderInlineMarkdown` stays in `markdown.js` beside
every other DOMPurify call, on the SAME instance and hooks, so a cell link
inherits the app-wide `target="_blank"` / `rel="noopener noreferrer"` hardening
and there is no second sanitizer (H-005).
Only strings are parsed. A number, boolean or object keeps exactly its
pre-#2771 rendering (`JSON.stringify` for an object, `String()` otherwise) and is
escaped rather than parsed — running a JSON blob through a markdown parser would
let its own `*` and `_` italicise a value nobody wrote as prose.
Headers render on the same terms as cells: an agent that bolds a column name and
bolds the values under it should not get two behaviours.
`ReportTable` is shared with reports (ent#537 / #1535), so report tables gain the
same rendering — the same defect, fixed once. `ReportRenderer.vue` is untouched,
so the `display_hint` / `shapeOk` pins in `test_1535_report_prompt_guidance.py`
are unaffected.
Verified: 17 unit cases over the real configured parser; a new e2e seeds a canvas
through the real `PUT .../canvas/{id}` route and asserts the rendered DOM — bold,
code, a hardened link, em/del, the fenced path, and the half a unit test
structurally cannot reach: a `<script>` + `<img onerror>` payload in a cell
leaves `window.__2771` undefined with zero `script`/`img` nodes in the table.
Red without the fix (the `<strong>` never appears), green with it. Full frontend
unit suite 133 files / 2933 tests green.
Fixes #2771
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
Stacked on #2782 (
fix/2197-body-horizontal-overflow) → #2781 → #2780 → #2778. Part of epic #1430.Fixes #2771
What was wrong
A
tableblock's cells were plain text.**Deploy**,`done`and[runbook](https://example.com)— what agents actually put in status and reference columns — leaked as literal characters, while the same canvas rendered a GFM pipe table written inmarkdownprose correctly. Two tables, two behaviours, and the one the MCP tool guide recommends (table = {columns, rows}) was the broken one. That inconsistency is the bug.Both structured paths are fixed at once, because both delegate to
ReportTable:CanvasBlockfor atableblock,CanvasMarkdownfor a```tablefence.Inline-only, deliberately
marked.parseInlinenever emits a block element, and the cell policy drops the ones raw HTML in a cell could smuggle in. A heading or a list in a cell degrades to its own text (DOMPurify'sKEEP_CONTENT) instead of breaking the row the #2583 gallery pins.Why the fix is in two files
markdown.jscannot be imported without a DOM — DOMPurify's DOM-less stub has noaddHook— and vitest runsenvironment: 'node'. Anything decided inside it is unreachable by a unit test.utils/inlineMarkdown.js— the decidable half: the parse, the markdown-vs-text decision, the escape, the tag/attr allowlist. Pure, and executed by the spec.markdown.js::renderInlineMarkdown— the sanitising half, on the same DOMPurify instance and hooks, so a cell link inherits the app-widetarget="_blank"/rel="noopener noreferrer"hardening and there is no second sanitizer (H-005).Only strings are parsed
A number, boolean or object keeps exactly its pre-#2771 rendering (
JSON.stringifyfor an object,String()otherwise) and is escaped, not parsed — running a JSON blob through a markdown parser would let its own*and_italicise a value nobody wrote as prose.null/undefinedstill render empty.Headers render on the same terms as cells: an agent that bolds a column name and bolds the values under it should not get two behaviours.
Shared with reports, on purpose
ReportTableis the single renderer for both (ent#537 / #1535), so report tables gain the same rendering — the same defect, fixed once.ReportRenderer.vueis untouched, so thedisplay_hint/shapeOkpins intest_1535_report_prompt_guidance.pyare unaffected.Verification
Unit (17 cases, real configured parser): bold / code / link / em / del; link hardening at the parser; no block markup for any of
# heading, lists, quote, fence, pipe table; the markdown-vs-text decision for strings, numbers, booleans, objects, arrays, null, undefined, missing column and empty string; escaping including the ampersand-first rule; and the policy — inline tags only, nothing that could break a row or reach the page (div,table,img,svg,iframe,script,style,form), noon*attribute, nostyle/class/src, and the object asserted to be the one handed to DOMPurify, frozen.E2E (the half a unit test structurally cannot reach): a canvas seeded through the real
PUT /api/agents/{name}/canvas/{id}route, then the rendered DOM asserted —<strong>,<code>, an<a href target=_blank rel=noopener>,<em>,<del>, the header, the fenced path, no literal**/~~/[…]anywhere in the table, non-strings unchanged, a heading in a cell present as text with zeroh1/ul/li/pre/tableinside a cell, and the sanitizer: a<script>+<img onerror>payload leaveswindow.__2771undefined with zeroscript/imgnodes and no dialog. Plus: the canvas page does not scroll horizontally.Negative control: with
ReportTable.vuereverted the e2e fails (td strongnever appears); restored, it passes.Full frontend unit suite: 133 files, 2933 tests green (includes the raw-color ratchet and the portal report specs).
Also
The MCP
set_canvaskind guide now says cells render inline markdown, so agents know the affordance exists.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ