Skip to content

Raw HTML in message text is rendered as live markup, silently truncating the transcript #105

Description

@KirkMasden

Summary

render_markdown_text() passes message text through markdown.markdown(), which by design passes raw HTML through to the output. Message text that contains literal HTML therefore becomes live markup in the generated page.

The practical consequence is worse than cosmetic: an unclosed <style> tag in a prompt causes the browser to parse the entire remainder of the page as CSS. The transcript appears to end mid-sentence, with no error. The content is present in the HTML file — it just never renders.

Reproduction

Any prompt containing literal HTML. Mine was a Claude Code instruction about editing an HTML file:

Leave the <head>, the existing <style> block, and all existing content exactly as they are.

Rendered output stops at "Leave the , the existing" — <head> is consumed as a tag, and <style> swallows everything after it. My session had ~2 further hours of content that was in the file but invisible.

A prompt containing <script> would likewise execute on page open. Local file, own content, so low practical risk — but "renders arbitrary HTML from message text" seems undesirable in a transcript viewer, particularly one with a --gist publishing path.

Cause

src/claude_code_transcripts/__init__.py:

def render_markdown_text(text):
    if not text:
        return ""
    return markdown.markdown(text, extensions=["fenced_code", "tables"])

Everything else in the codebase escapes correctly — html.escape() throughout, autoescape=True, and the |safe filters in the templates are all legitimate (those values genuinely are pre-rendered HTML). This one path is the gap.

Suggested fix

Deregister Python-Markdown's HTML pass-through handlers so tags render as visible text:

def render_markdown_text(text):
    if not text:
        return ""
    md = markdown.Markdown(extensions=["fenced_code", "tables"])
    md.preprocessors.deregister("html_block")
    md.inlinePatterns.deregister("html")
    return md.convert(text)

This preserves fenced code blocks correctly — fenced_code_block runs as a preprocessor ahead of html_block, so fenced content is stashed before the HTML handlers would see it. A naive escape-the-input-first approach would double-escape inside fences; this doesn't.

Verification

Tested against Python-Markdown 3.10.2:

  • Leave the <head>, the existing <style> block → escaped, visible as text ✅
  • <script>alert(1)</script> → escaped, not executed ✅
  • Fenced code block containing HTML → escaped exactly once, no &amp;lt; ✅
  • Bold, inline code, tables → byte-for-byte identical to unpatched output ✅
  • Tom & Jerry → &amp;, not double-escaped ✅

Regenerating my real 1 MB session transcript with the patch: raw <style> count in index.html drops from 2 to 1 (only the tool's own tag from base.html), and the previously-invisible content renders.

Activity

  1. benswift commented on Aug 13, 2026

    @benswift

    Another input route to the same render_markdown_text() gap, in case it's
    useful as a test case: indented fenced code blocks.

    fenced_code only recognises a fence starting at column 0, so a fence indented
    1–3 spaces inside a list item is never stashed by the preprocessor. Its contents
    are re-parsed as markdown, and HTML inside a code block therefore goes live —
    same mechanism you've identified, but arriving from text the author explicitly
    marked as code.

    Verified against Python-Markdown 3.10.3, and end to end through the tool at 0.6:
    an assistant message containing a html fence indented two spaces under a
    bullet puts a live, unescaped <div> into page-001.html.

    Your suggested fix handles this correctly — deregistering html_block/html
    makes the contents render as visible text instead of live markup, which is the
    safety half. It doesn't make them render as a code block, so I've filed the
    rendering-fidelity side separately as #110 rather than muddy this one.

    The reason it's worth a test case: compaction summaries. Ordinary assistant
    prose puts fences at column 0, but a summary is a deeply nested bullet list full
    of indented code samples, so any session long enough to have compacted tends to
    carry a batch of these at once.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions