From e897eda9f06a3f4b97705f1a5e2d3f515010aecc Mon Sep 17 00:00:00 2001 From: Christian Boos Date: Thu, 2 Jul 2026 19:28:05 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Render=20the=20Artifact=20tool=20(deploy=20?= =?UTF-8?q?HTML/MD=20page=20=E2=86=92=20claude.ai=20URL)=20(#257)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typed input/output models + HTML and Markdown renderers for the Artifact tool (Claude Code 2.1.172+): title '๐Ÿ–ผ๏ธ Artifact ', body with description/favicon/label/redeploy metadata, result as 'Published โ†’ <url>'. Shapes match the wire format observed on Claude Code 2.1.198 (toolUseResult {url, path, title}; title is the page <title> for .html sources, the file basename for .md ones). The page content never reaches the transcript (the tool takes a file path), but every string field is transcript-derived: all fields render escaped (#245 contract) and URLs only become links for http(s) schemes โ€” a hostile javascript: URL stays inert text in both renderers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- claude_code_log/factories/tool_factory.py | 60 +++ claude_code_log/html/__init__.py | 4 + claude_code_log/html/renderer.py | 19 + claude_code_log/html/tool_formatters.py | 76 ++++ claude_code_log/markdown/renderer.py | 59 +++ claude_code_log/models.py | 38 ++ dev-docs/messages.md | 1 + .../messages/tools/Artifact-tool_result.json | 24 ++ .../messages/tools/Artifact-tool_result.jsonl | 1 + .../messages/tools/Artifact-tool_use.json | 27 ++ .../messages/tools/Artifact-tool_use.jsonl | 1 + test/test_artifact_rendering.py | 393 ++++++++++++++++++ test/test_data/artifact_tool.jsonl | 11 + 13 files changed, 714 insertions(+) create mode 100644 dev-docs/messages/tools/Artifact-tool_result.json create mode 100644 dev-docs/messages/tools/Artifact-tool_result.jsonl create mode 100644 dev-docs/messages/tools/Artifact-tool_use.json create mode 100644 dev-docs/messages/tools/Artifact-tool_use.jsonl create mode 100644 test/test_artifact_rendering.py create mode 100644 test/test_data/artifact_tool.jsonl diff --git a/claude_code_log/factories/tool_factory.py b/claude_code_log/factories/tool_factory.py index 6ffb7401..a0356201 100644 --- a/claude_code_log/factories/tool_factory.py +++ b/claude_code_log/factories/tool_factory.py @@ -62,6 +62,7 @@ SkillInput, WebSearchInput, WebFetchInput, + ArtifactInput, WriteInput, # Tool output models AskUserQuestionAnswer, @@ -85,6 +86,7 @@ WebSearchLink, WebSearchOutput, WebFetchOutput, + ArtifactOutput, WriteOutput, ) @@ -115,6 +117,9 @@ "ExitPlanMode": ExitPlanModeInput, "WebSearch": WebSearchInput, "WebFetch": WebFetchInput, + # Deploys a self-contained HTML/Markdown file as a claude.ai page + # (Claude Code 2.1.172+, issue #257). + "Artifact": ArtifactInput, "Monitor": MonitorInput, "ScheduleWakeup": ScheduleWakeupInput, "CronCreate": CronCreateInput, @@ -918,6 +923,59 @@ def parse_webfetch_output( return None +# Text form of a successful Artifact deploy: ``Published <path> at <url>``. +# Fallback for entries that lack the structured toolUseResult. +_ARTIFACT_PUBLISHED_RE = re.compile( + r"^Published (?P<path>.+) at (?P<url>https?://\S+)$" +) + + +def parse_artifact_output( + tool_result: ToolResultContent, + file_path: Optional[str], + tool_use_result: Optional[ToolUseResult] = None, +) -> Optional[ArtifactOutput]: + """Parse Artifact tool result from structured toolUseResult or text. + + Wire format (observed on Claude Code 2.1.198): + + - toolUseResult: ``{url, path, title}`` โ€” ``title`` is the page's + ``<title>`` for HTML sources, the file basename for Markdown ones. + - text content: ``Published <path> at <url>`` on success; a plain + error message (``is_error: true``, no toolUseResult) on denial. + + Args: + tool_result: The tool result content (used as fallback) + file_path: Unused for the Artifact tool + tool_use_result: Structured result with url/path/title + + Returns: + ArtifactOutput if parsing succeeds, None otherwise + """ + del file_path # Unused + + if isinstance(tool_use_result, dict): + url = tool_use_result.get("url") + if isinstance(url, str) and url: + path = tool_use_result.get("path") + title = tool_use_result.get("title") + return ArtifactOutput( + url=url, + path=path if isinstance(path, str) else None, + title=title if isinstance(title, str) else None, + ) + + # Fallback: text content โ€” the success line, or an error/denial message. + text = _extract_tool_result_text(tool_result) + if text: + match = _ARTIFACT_PUBLISHED_RE.match(text.strip()) + if match: + return ArtifactOutput(url=match.group("url"), path=match.group("path")) + return ArtifactOutput(raw_text=text) + + return None + + # Match ``Monitor started (task <id>, โ€ฆ)`` so the task id is available # to downstream consumers without re-parsing the full body. The id is # the short alphanumeric form (e.g. ``b07h5t4ng``) the harness echoes @@ -1421,6 +1479,7 @@ def parse_taskstop_output( "ExitPlanMode": parse_exitplanmode_output, "WebSearch": parse_websearch_output, "WebFetch": parse_webfetch_output, + "Artifact": parse_artifact_output, "Monitor": parse_monitor_output, "ScheduleWakeup": parse_schedulewakeup_output, "CronCreate": parse_croncreate_output, @@ -1443,6 +1502,7 @@ def parse_taskstop_output( PARSERS_WITH_TOOL_USE_RESULT: set[str] = { "WebSearch", "WebFetch", + "Artifact", "Bash", "TaskStop", "Read", diff --git a/claude_code_log/html/__init__.py b/claude_code_log/html/__init__.py index 2bfd2c62..01b28290 100644 --- a/claude_code_log/html/__init__.py +++ b/claude_code_log/html/__init__.py @@ -17,6 +17,7 @@ ) from .tool_formatters import ( # Tool input formatters (called by HtmlRenderer.format_{InputClass}) + format_artifact_input, format_askuserquestion_input, format_bash_input, format_edit_input, @@ -28,6 +29,7 @@ format_webfetch_input, format_write_input, # Tool output formatters (called by HtmlRenderer.format_{OutputClass}) + format_artifact_output, format_askuserquestion_output, format_bash_output, format_edit_output, @@ -110,6 +112,8 @@ "format_read_input", "format_task_input", "format_todowrite_input", + "format_artifact_input", + "format_artifact_output", "format_webfetch_input", "format_write_input", # tool_formatters (output) - called by HtmlRenderer.format_{OutputClass} diff --git a/claude_code_log/html/renderer.py b/claude_code_log/html/renderer.py index 7911508e..92900896 100644 --- a/claude_code_log/html/renderer.py +++ b/claude_code_log/html/renderer.py @@ -58,6 +58,7 @@ ToolUseContent, SkillInput, WebSearchInput, + ArtifactInput, WebFetchInput, WorkflowToolInput, MonitorInput, @@ -88,6 +89,7 @@ TeamDeleteOutput, ToolResultContent, WebSearchOutput, + ArtifactOutput, WebFetchOutput, WriteOutput, ) @@ -171,6 +173,8 @@ format_grep_input, format_websearch_input, format_websearch_output, + format_artifact_input, + format_artifact_output, format_webfetch_input, format_workflow_input, format_workflow_phase_content, @@ -853,6 +857,14 @@ def format_WebFetchOutput(self, output: WebFetchOutput, _: TemplateMessage) -> s """Format โ†’ collapsible markdown with metadata badge.""" return format_webfetch_output(output) + def format_ArtifactInput(self, input: ArtifactInput, _: TemplateMessage) -> str: + """Format โ†’ description + favicon/label/redeploy meta line.""" + return format_artifact_input(input) + + def format_ArtifactOutput(self, output: ArtifactOutput, _: TemplateMessage) -> str: + """Format โ†’ 'Published <title> โ†’ <url>' with scheme-guarded link.""" + return format_artifact_output(output) + def format_MonitorInput(self, input: MonitorInput, _: TemplateMessage) -> str: """Format โ†’ 4-row params table with collapsible command.""" return format_monitor_input(input) @@ -1212,6 +1224,13 @@ def title_WebFetchInput( """Title โ†’ '๐ŸŒ WebFetch <url>'.""" return self._tool_title(message, "๐ŸŒ", input.url) + def title_ArtifactInput( + self, input: ArtifactInput, message: TemplateMessage + ) -> str: + """Title โ†’ '๐Ÿ–ผ๏ธ Artifact <file_path>' (same full-path recipe as + Read/Write โ€” the deployed page is identified by its source file).""" + return self._tool_title(message, "๐Ÿ–ผ๏ธ", input.file_path) + def title_MonitorInput(self, input: MonitorInput, message: TemplateMessage) -> str: """Title โ†’ '๐Ÿ”ญ Monitor <description>'.""" return self._tool_title(message, "๐Ÿ”ญ", input.description) diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 63c57085..650f5db1 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -69,6 +69,8 @@ ToolResultContent, WebSearchInput, WebSearchOutput, + ArtifactInput, + ArtifactOutput, WebFetchInput, WebFetchOutput, WorkflowAgentMessage, @@ -857,6 +859,78 @@ def format_webfetch_output(output: WebFetchOutput) -> str: return f"{badge_html}{content_html}" +# -- Artifact Tool ------------------------------------------------------------ + + +def _artifact_link(url: str) -> str: + """Render an artifact URL as a link, or as escaped text for odd schemes. + + The URL is transcript-derived and thus attacker-controllable; escaping + alone doesn't neutralise a hostile scheme (``javascript:`` survives + ``escape_html`` intact), so only emit an ``href`` for http(s) URLs. + """ + escaped = escape_html(url) + if url.startswith(("https://", "http://")): + return f'<a href="{escaped}">{escaped}</a>' + return escaped + + +def format_artifact_input(artifact_input: ArtifactInput) -> str: + """Format Artifact tool use content. + + The source file path is shown in the title; the body carries the + optional metadata (description, favicon, version label, redeploy + target) when present. The page content itself never appears in the + transcript โ€” the tool takes a file path โ€” so there is nothing to + render live; every field is escaped. + """ + rows: list[str] = [] + if artifact_input.description: + rows.append( + f'<div class="artifact-description">' + f"{escape_html(artifact_input.description)}</div>" + ) + meta_parts: list[str] = [] + if artifact_input.favicon: + meta_parts.append( + f'<span class="artifact-favicon">{escape_html(artifact_input.favicon)}</span>' + ) + if artifact_input.label: + meta_parts.append( + f'<span class="artifact-label">{escape_html(artifact_input.label)}</span>' + ) + if artifact_input.url: + meta_parts.append( + f'<span class="artifact-redeploy">redeploys {_artifact_link(artifact_input.url)}</span>' + ) + if artifact_input.force: + meta_parts.append('<span class="artifact-force">force</span>') + if meta_parts: + rows.append(f'<div class="artifact-meta">{" ยท ".join(meta_parts)}</div>') + return "".join(rows) + + +def format_artifact_output(output: ArtifactOutput) -> str: + """Format Artifact tool result โ€” published page title + link. + + Falls back to the escaped raw text for error/denial results that + carry no structured data. + """ + if output.url: + title_html = ( + f'<span class="artifact-title">{escape_html(output.title)}</span> โ†’ ' + if output.title + else "" + ) + return ( + f'<div class="artifact-output">Published ' + f"{title_html}{_artifact_link(output.url)}</div>" + ) + if output.raw_text: + return f'<div class="artifact-output">{escape_html(output.raw_text)}</div>' + return "" + + # -- Monitor Tool ------------------------------------------------------------- @@ -1683,6 +1757,8 @@ def format_workflow_agent_content(content: WorkflowAgentMessage) -> str: "format_taskstop_input", "format_grep_input", "format_websearch_input", + "format_artifact_input", + "format_artifact_output", "format_webfetch_input", "format_workflow_input", "format_workflow_phase_content", diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index 76c39c7a..ad80c2f2 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -68,6 +68,7 @@ ToolUseContent, SkillInput, WebSearchInput, + ArtifactInput, WebFetchInput, WorkflowAgentMessage, WorkflowPhaseMessage, @@ -100,6 +101,7 @@ TeamDeleteOutput, ToolResultContent, WebSearchOutput, + ArtifactOutput, WebFetchOutput, WriteOutput, ) @@ -304,6 +306,21 @@ def safe_markdown_inline(text: str) -> str: return _protect_html_tags(text) if "<" in text else text +def _markdown_safe_url(url: str) -> str: + """Render a transcript-derived URL as a Markdown link, or inline code. + + Only http(s) URLs become clickable โ€” escaping alone doesn't neutralise + a hostile scheme (``javascript:`` would survive into a live link once a + downstream viewer converts the Markdown to HTML). The link target gets + its parentheses percent-encoded so it can't break out of the ``(url)`` + destination syntax. + """ + if url.startswith(("https://", "http://")): + target = url.replace("(", "%28").replace(")", "%29") + return f"[{safe_markdown_inline(url)}]({target})" + return _inline_code(url) + + def _render_expand_paths_tree(template_projects: list[Any]) -> list[str]: """Render `--expand-paths` Markdown index as a nested bullet-list directory tree. @@ -1075,6 +1092,29 @@ def format_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str: return self._code_fence(input.prompt) return "" + def format_ArtifactInput(self, input: ArtifactInput, _: TemplateMessage) -> str: + """Format โ†’ description + favicon/label/redeploy meta lines. + + The page content never reaches the transcript (the tool takes a + file path), so the body only carries the optional metadata; the + strings are inert as Markdown via ``safe_markdown_inline``. + """ + parts: list[str] = [] + if input.description: + parts.append(f"*{safe_markdown_inline(input.description)}*") + meta_parts: list[str] = [] + if input.favicon: + meta_parts.append(safe_markdown_inline(input.favicon)) + if input.label: + meta_parts.append(safe_markdown_inline(input.label)) + if input.url: + meta_parts.append(f"redeploys {_markdown_safe_url(input.url)}") + if input.force: + meta_parts.append("force") + if meta_parts: + parts.append(" ยท ".join(meta_parts)) + return "\n\n".join(parts) + def format_WorkflowToolInput( self, input: WorkflowToolInput, _: TemplateMessage ) -> str: @@ -1629,6 +1669,21 @@ def format_WebFetchOutput(self, output: WebFetchOutput, _: TemplateMessage) -> s meta_line = f"*{' ยท '.join(meta_parts)}*\n\n" if meta_parts else "" return meta_line + self._quote(output.result) + def format_ArtifactOutput(self, output: ArtifactOutput, _: TemplateMessage) -> str: + """Format โ†’ 'Published <title> โ†’ <link>', or quoted raw text. + + The raw-text branch carries error/denial messages (no structured + toolUseResult); quote it like other verbatim harness output. + """ + if output.url: + title_part = ( + f"**{safe_markdown_inline(output.title)}** โ†’ " if output.title else "" + ) + return f"Published {title_part}{_markdown_safe_url(output.url)}" + if output.raw_text: + return self._quote(output.raw_text) + return "" + # --- Teammate-feature tool outputs ------------------------------------- def format_TeamCreateOutput( @@ -1933,6 +1988,10 @@ def title_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str: url = input.url[:60] + "โ€ฆ" if len(input.url) > 60 else input.url return f"๐ŸŒ WebFetch `{url}`" + def title_ArtifactInput(self, input: ArtifactInput, _: TemplateMessage) -> str: + """Title โ†’ '๐Ÿ–ผ๏ธ Artifact `file_path`'.""" + return f"๐Ÿ–ผ๏ธ Artifact {_inline_code(input.file_path)}" + def title_MonitorInput(self, input: MonitorInput, _: TemplateMessage) -> str: """Title โ†’ '๐Ÿ”ญ Monitor `<description>`'. diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 07e1fa4f..194dce40 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -1482,6 +1482,26 @@ class WebFetchInput(BaseModel): prompt: str +class ArtifactInput(BaseModel): + """Input parameters for the Artifact tool. + + Deploys a self-contained HTML or Markdown file as a (default-private) + claude.ai web page. Only ``file_path`` and ``favicon`` are required in + the current schema; keep the rest optional and tolerate unknown fields + so schema tweaks across Claude Code versions don't drop the message to + the generic params table. + """ + + file_path: str + favicon: str = "" + description: Optional[str] = None + label: Optional[str] = None + url: Optional[str] = None # Existing artifact URL to redeploy to + force: Optional[bool] = None + + model_config = {"extra": "allow"} + + class SkillInput(BaseModel): """Input parameters for the Skill tool.""" @@ -1666,6 +1686,7 @@ class WorkflowToolInput(BaseModel): ExitPlanModeInput, WebSearchInput, WebFetchInput, + ArtifactInput, MonitorInput, ScheduleWakeupInput, CronCreateInput, @@ -2037,6 +2058,22 @@ class WebFetchOutput: duration_ms: Optional[int] = None # Time taken in milliseconds +@dataclass +class ArtifactOutput: + """Parsed Artifact tool output. + + Symmetric with ArtifactInput for tool_use โ†’ tool_result pairing. + Wire format (observed on Claude Code 2.1.198): text content + ``Published <path> at <url>`` plus a structured toolUseResult + ``{url, path, title}`` where ``title`` is extracted from the page. + """ + + url: Optional[str] = None # Deployed page URL + path: Optional[str] = None # Source file that was published + title: Optional[str] = None # Page title extracted by the harness + raw_text: Optional[str] = None # Fallback text when structured data absent + + # ============================================================================= # Teammates feature tool outputs # ============================================================================= @@ -2176,6 +2213,7 @@ class TaskStopOutput: ExitPlanModeOutput, WebSearchOutput, WebFetchOutput, + ArtifactOutput, MonitorOutput, ScheduleWakeupOutput, CronCreateOutput, diff --git a/dev-docs/messages.md b/dev-docs/messages.md index e6fcbccd..c93ed27f 100644 --- a/dev-docs/messages.md +++ b/dev-docs/messages.md @@ -977,6 +977,7 @@ Sub-agent messages (from `Task` tool): |------|------------|---------------|-------------|--------------| | WebFetch | [tool_use](messages/tools/WebFetch-tool_use.json) | [tool_result](messages/tools/WebFetch-tool_result.json) | โ€” | โ€” | | WebSearch | [tool_use](messages/tools/WebSearch-tool_use.json) | [tool_result](messages/tools/WebSearch-tool_result.json) | โ€” | โ€” | +| Artifact *(deploy HTML/MD page, CC 2.1.172+)* | [tool_use](messages/tools/Artifact-tool_use.json) | [tool_result](messages/tools/Artifact-tool_result.json) | `ArtifactInput` | `ArtifactOutput` | --- diff --git a/dev-docs/messages/tools/Artifact-tool_result.json b/dev-docs/messages/tools/Artifact-tool_result.json new file mode 100644 index 00000000..fbd478f8 --- /dev/null +++ b/dev-docs/messages/tools/Artifact-tool_result.json @@ -0,0 +1,24 @@ +{ + "parentUuid": "21fba4a4-f5e6-4420-a4e8-be64383362f9", + "isSidechain": false, + "type": "user", + "message": { + "role": "user", + "content": [ + { + "tool_use_id": "toolu_01KFHHG1ptbGeZQK3epbQxhX", + "type": "tool_result", + "content": "Published /workspace/demo/artifact-shape-probe.html at https://claude.ai/code/artifact/e897407d-f44d-4654-b436-69973ce1964a" + } + ] + }, + "uuid": "6e66c413-4156-4759-a807-bd371fd7ebeb", + "timestamp": "2026-07-02T17:09:30.242Z", + "toolUseResult": { + "url": "https://claude.ai/code/artifact/e897407d-f44d-4654-b436-69973ce1964a", + "path": "/workspace/demo/artifact-shape-probe.html", + "title": "Artifact shape probe" + }, + "sessionId": "cfa88393-fc66-480f-8762-fa85a33d1d9f", + "version": "2.1.198" +} diff --git a/dev-docs/messages/tools/Artifact-tool_result.jsonl b/dev-docs/messages/tools/Artifact-tool_result.jsonl new file mode 100644 index 00000000..23df4fe8 --- /dev/null +++ b/dev-docs/messages/tools/Artifact-tool_result.jsonl @@ -0,0 +1 @@ +{"parentUuid":"21fba4a4-f5e6-4420-a4e8-be64383362f9","isSidechain":false,"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01KFHHG1ptbGeZQK3epbQxhX","type":"tool_result","content":"Published /workspace/demo/artifact-shape-probe.html at https://claude.ai/code/artifact/e897407d-f44d-4654-b436-69973ce1964a"}]},"uuid":"6e66c413-4156-4759-a807-bd371fd7ebeb","timestamp":"2026-07-02T17:09:30.242Z","toolUseResult":{"url":"https://claude.ai/code/artifact/e897407d-f44d-4654-b436-69973ce1964a","path":"/workspace/demo/artifact-shape-probe.html","title":"Artifact shape probe"},"sessionId":"cfa88393-fc66-480f-8762-fa85a33d1d9f","version":"2.1.198"} diff --git a/dev-docs/messages/tools/Artifact-tool_use.json b/dev-docs/messages/tools/Artifact-tool_use.json new file mode 100644 index 00000000..d342c66c --- /dev/null +++ b/dev-docs/messages/tools/Artifact-tool_use.json @@ -0,0 +1,27 @@ +{ + "type": "assistant", + "sessionId": "cfa88393-fc66-480f-8762-fa85a33d1d9f", + "timestamp": "2026-07-02T16:57:43.795Z", + "uuid": "21fba4a4-f5e6-4420-a4e8-be64383362f9", + "parentUuid": "6998eedd-1e25-4ad0-9325-bd2554ac4255", + "isSidechain": false, + "message": { + "role": "assistant", + "type": "message", + "model": "claude-fable-5", + "id": "msg_01UgmX8QWNGApwj8gFAK3EbW", + "content": [ + { + "type": "tool_use", + "id": "toolu_01KFHHG1ptbGeZQK3epbQxhX", + "name": "Artifact", + "input": { + "file_path": "/workspace/demo/artifact-shape-probe.html", + "favicon": "๐Ÿ”ฌ", + "description": "Throwaway probe to capture the Artifact tool's JSONL wire format (issue #257).", + "label": "shape-probe" + } + } + ] + } +} diff --git a/dev-docs/messages/tools/Artifact-tool_use.jsonl b/dev-docs/messages/tools/Artifact-tool_use.jsonl new file mode 100644 index 00000000..67105ca4 --- /dev/null +++ b/dev-docs/messages/tools/Artifact-tool_use.jsonl @@ -0,0 +1 @@ +{"type":"assistant","sessionId":"cfa88393-fc66-480f-8762-fa85a33d1d9f","timestamp":"2026-07-02T16:57:43.795Z","uuid":"21fba4a4-f5e6-4420-a4e8-be64383362f9","parentUuid":"6998eedd-1e25-4ad0-9325-bd2554ac4255","isSidechain":false,"message":{"role":"assistant","type":"message","model":"claude-fable-5","id":"msg_01UgmX8QWNGApwj8gFAK3EbW","content":[{"type":"tool_use","id":"toolu_01KFHHG1ptbGeZQK3epbQxhX","name":"Artifact","input":{"file_path":"/workspace/demo/artifact-shape-probe.html","favicon":"๐Ÿ”ฌ","description":"Throwaway probe to capture the Artifact tool's JSONL wire format (issue #257).","label":"shape-probe"}}]}} diff --git a/test/test_artifact_rendering.py b/test/test_artifact_rendering.py new file mode 100644 index 00000000..d3516873 --- /dev/null +++ b/test/test_artifact_rendering.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""Test cases for Artifact tool rendering (issue #257). + +The Artifact tool deploys a self-contained HTML or Markdown file as a +claude.ai web page. The page content never reaches the transcript (the +tool takes a file path), but every string field โ€” description, label, +title, URL โ€” is transcript-derived and must render escaped, and the +URL must never become a clickable link for a non-http(s) scheme. +""" + +from pathlib import Path + +from claude_code_log.converter import load_transcript +from claude_code_log.factories.tool_factory import ( + create_tool_input, + parse_artifact_output, +) +from claude_code_log.html import format_artifact_input, format_artifact_output +from claude_code_log.html.renderer import HtmlRenderer, generate_html +from claude_code_log.markdown.renderer import MarkdownRenderer +from claude_code_log.models import ( + ArtifactInput, + ArtifactOutput, + MessageMeta, + ToolResultContent, + ToolResultMessage, + ToolUseMessage, +) +from claude_code_log.renderer import TemplateMessage + + +def _input_message(artifact_input: ArtifactInput) -> TemplateMessage: + return TemplateMessage( + ToolUseMessage( + MessageMeta.empty(), + input=artifact_input, + tool_use_id="toolu_artifact", + tool_name="Artifact", + ) + ) + + +def _output_message(output: ArtifactOutput) -> TemplateMessage: + return TemplateMessage( + ToolResultMessage( + MessageMeta.empty(), + output=output, + tool_use_id="toolu_artifact", + tool_name="Artifact", + ) + ) + + +class TestArtifactInput: + """Test Artifact input model and HTML formatting.""" + + def test_input_minimal(self): + artifact_input = ArtifactInput(file_path="/workspace/page.html") + assert artifact_input.file_path == "/workspace/page.html" + assert artifact_input.favicon == "" + assert artifact_input.description is None + assert artifact_input.url is None + + def test_input_full(self): + artifact_input = ArtifactInput( + file_path="/workspace/page.html", + favicon="๐Ÿ“Š", + description="A dashboard", + label="v2", + url="https://claude.ai/code/artifact/abc", + force=True, + ) + assert artifact_input.favicon == "๐Ÿ“Š" + assert artifact_input.force is True + + def test_input_tolerates_unknown_fields(self): + """Schema drift across Claude Code versions must not drop the + message to the generic params table.""" + parsed = create_tool_input( + "Artifact", + {"file_path": "/workspace/page.html", "favicon": "๐Ÿ“Š", "novel": "field"}, + ) + assert isinstance(parsed, ArtifactInput) + + def test_format_input_metadata(self): + html = format_artifact_input( + ArtifactInput( + file_path="/workspace/page.html", + favicon="๐Ÿ“Š", + description="A dashboard", + label="v2", + ) + ) + assert "A dashboard" in html + assert "๐Ÿ“Š" in html + assert "v2" in html + + def test_format_input_redeploy_link(self): + html = format_artifact_input( + ArtifactInput( + file_path="/workspace/page.html", + favicon="๐Ÿ“Š", + url="https://claude.ai/code/artifact/abc", + force=True, + ) + ) + assert '<a href="https://claude.ai/code/artifact/abc">' in html + assert "force" in html + + def test_format_input_escapes_html(self): + """XSS guard: hostile description/label render escaped, never live.""" + html = format_artifact_input( + ArtifactInput( + file_path="/workspace/page.html", + favicon="๐Ÿงช", + description="<img src=x onerror=alert(1)>", + label="<b>bold</b>", + ) + ) + assert "<img" not in html + assert "<img" in html + assert "<b>" not in html + + def test_format_input_javascript_url_not_linked(self): + """A hostile scheme must not become a clickable redeploy link.""" + html = format_artifact_input( + ArtifactInput( + file_path="/workspace/page.html", + favicon="๐Ÿงช", + url="javascript:alert(1)", + ) + ) + assert "href" not in html + assert "javascript:alert(1)" in html # Shown as text, not a link + + +class TestArtifactParser: + """Test Artifact output parsing.""" + + def test_parse_structured_output(self): + tool_result = ToolResultContent( + type="tool_result", + tool_use_id="toolu_test", + content="Published /workspace/page.html at https://claude.ai/code/artifact/abc", + ) + output = parse_artifact_output( + tool_result, + None, + { + "url": "https://claude.ai/code/artifact/abc", + "path": "/workspace/page.html", + "title": "My page", + }, + ) + assert isinstance(output, ArtifactOutput) + assert output.url == "https://claude.ai/code/artifact/abc" + assert output.path == "/workspace/page.html" + assert output.title == "My page" + assert output.raw_text is None + + def test_parse_structured_minimal(self): + tool_result = ToolResultContent( + type="tool_result", tool_use_id="toolu_test", content="" + ) + output = parse_artifact_output( + tool_result, None, {"url": "https://claude.ai/code/artifact/abc"} + ) + assert isinstance(output, ArtifactOutput) + assert output.url == "https://claude.ai/code/artifact/abc" + assert output.path is None + assert output.title is None + + def test_parse_text_fallback_published_line(self): + tool_result = ToolResultContent( + type="tool_result", + tool_use_id="toolu_test", + content="Published /workspace/page.html at https://claude.ai/code/artifact/abc", + ) + output = parse_artifact_output(tool_result, None, None) + assert isinstance(output, ArtifactOutput) + assert output.url == "https://claude.ai/code/artifact/abc" + assert output.path == "/workspace/page.html" + + def test_parse_text_fallback_error_message(self): + """Denied/error results (is_error, no toolUseResult) keep raw text.""" + tool_result = ToolResultContent( + type="tool_result", + tool_use_id="toolu_test", + content="Permission for this action was denied by the classifier.", + ) + output = parse_artifact_output(tool_result, None, None) + assert isinstance(output, ArtifactOutput) + assert output.url is None + assert output.raw_text is not None + assert "denied" in output.raw_text + + def test_parse_empty_returns_none(self): + tool_result = ToolResultContent( + type="tool_result", tool_use_id="toolu_test", content="" + ) + assert parse_artifact_output(tool_result, None, None) is None + + +class TestArtifactOutputFormatting: + """Test Artifact output HTML formatting.""" + + def test_format_output_full(self): + html = format_artifact_output( + ArtifactOutput( + url="https://claude.ai/code/artifact/abc", + path="/workspace/page.html", + title="My page", + ) + ) + assert "Published" in html + assert "My page" in html + assert '<a href="https://claude.ai/code/artifact/abc">' in html + + def test_format_output_no_title(self): + html = format_artifact_output( + ArtifactOutput(url="https://claude.ai/code/artifact/abc") + ) + assert "Published" in html + assert '<a href="https://claude.ai/code/artifact/abc">' in html + + def test_format_output_raw_text(self): + html = format_artifact_output(ArtifactOutput(raw_text="Denied by policy")) + assert "Denied by policy" in html + assert "<a " not in html + + def test_format_output_empty(self): + assert format_artifact_output(ArtifactOutput()) == "" + + def test_format_output_escapes_hostile_title(self): + """XSS guard: a hostile page title renders escaped, never live.""" + html = format_artifact_output( + ArtifactOutput( + url="https://claude.ai/code/artifact/abc", + title="<script>alert(1)</script>", + ) + ) + assert "<script>" not in html + assert "<script>" in html + + def test_format_output_javascript_url_not_linked(self): + """A hostile scheme in toolUseResult.url must not become an href.""" + html = format_artifact_output( + ArtifactOutput(url="javascript:alert(1)", title="page") + ) + assert "href" not in html + assert "javascript:alert(1)" in html # Escaped text, not a link + + +class TestArtifactHtmlRenderer: + """Test HtmlRenderer dispatch methods.""" + + def test_title(self): + renderer = HtmlRenderer() + artifact_input = ArtifactInput( + file_path="/workspace/dashboard.html", favicon="๐Ÿ“Š" + ) + title = renderer.title_ArtifactInput( + artifact_input, _input_message(artifact_input) + ) + assert "๐Ÿ–ผ๏ธ" in title + assert "/workspace/dashboard.html" in title + + def test_format_input_dispatch(self): + renderer = HtmlRenderer() + artifact_input = ArtifactInput( + file_path="/workspace/dashboard.html", + favicon="๐Ÿ“Š", + description="A dashboard", + ) + html = renderer.format_ArtifactInput( + artifact_input, _input_message(artifact_input) + ) + assert "A dashboard" in html + + def test_format_output_dispatch(self): + renderer = HtmlRenderer() + output = ArtifactOutput( + url="https://claude.ai/code/artifact/abc", title="My page" + ) + html = renderer.format_ArtifactOutput(output, _output_message(output)) + assert "My page" in html + + +class TestArtifactMarkdownRenderer: + """Test MarkdownRenderer dispatch methods.""" + + def test_title(self): + renderer = MarkdownRenderer() + artifact_input = ArtifactInput( + file_path="/workspace/dashboard.html", favicon="๐Ÿ“Š" + ) + title = renderer.title_ArtifactInput( + artifact_input, _input_message(artifact_input) + ) + assert title == "๐Ÿ–ผ๏ธ Artifact `/workspace/dashboard.html`" + + def test_format_input(self): + renderer = MarkdownRenderer() + artifact_input = ArtifactInput( + file_path="/workspace/dashboard.html", + favicon="๐Ÿ“Š", + description="A dashboard", + label="v2", + ) + md = renderer.format_ArtifactInput( + artifact_input, _input_message(artifact_input) + ) + assert "*A dashboard*" in md + assert "๐Ÿ“Š ยท v2" in md + + def test_format_output_link(self): + renderer = MarkdownRenderer() + output = ArtifactOutput( + url="https://claude.ai/code/artifact/abc", title="My page" + ) + md = renderer.format_ArtifactOutput(output, _output_message(output)) + assert "**My page**" in md + assert "(https://claude.ai/code/artifact/abc)" in md + + def test_format_output_javascript_url_not_linked(self): + """A hostile scheme must render as inline code, not a Markdown link.""" + renderer = MarkdownRenderer() + output = ArtifactOutput(url="javascript:alert(1)") + md = renderer.format_ArtifactOutput(output, _output_message(output)) + assert "](javascript:" not in md + assert "`javascript:alert(1)`" in md + + def test_format_output_paren_url_stays_inside_link_target(self): + """Parentheses in the URL must not break out of the (url) syntax.""" + renderer = MarkdownRenderer() + output = ArtifactOutput(url="https://example.com/a(b)c") + md = renderer.format_ArtifactOutput(output, _output_message(output)) + assert "(https://example.com/a%28b%29c)" in md + + def test_format_output_raw_text_quoted(self): + renderer = MarkdownRenderer() + output = ArtifactOutput(raw_text="Denied by policy") + md = renderer.format_ArtifactOutput(output, _output_message(output)) + assert md.startswith("> ") + + +class TestArtifactEndToEnd: + """Render the checked-in fixture through both pipelines.""" + + FIXTURE = Path(__file__).parent / "test_data" / "artifact_tool.jsonl" + + def test_html_end_to_end(self): + messages = load_transcript(self.FIXTURE) + html = generate_html(messages, "Artifact Test") + + # Success + redeploy + markdown variants all render with links + assert "Flaky tests report" in html + assert ( + '<a href="https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555">' + in html + ) + assert "notes.md" in html + # Denied variant renders its text + assert "denied by the Claude Code auto mode classifier" in html + # Title icon suppresses the default wrench (no double icon) + assert "๐Ÿ–ผ๏ธ" in html + assert "๐Ÿ› ๏ธ ๐Ÿ–ผ๏ธ" not in html + + def test_html_end_to_end_xss(self): + """The hostile entry's payloads must never survive unescaped.""" + messages = load_transcript(self.FIXTURE) + html = generate_html(messages, "Artifact XSS Test") + + assert "<script>alert(1)</script>" not in html + assert "<img src=x onerror=alert(1)>" not in html + assert 'href="javascript:' not in html + assert "href='javascript:" not in html + + def test_markdown_end_to_end_xss(self): + messages = load_transcript(self.FIXTURE) + renderer = MarkdownRenderer() + md = renderer.generate(messages, "Artifact XSS Test") + + # No hostile scheme survives into a Markdown link destination. + assert "](javascript:" not in md + # Inline-text surfaces are entity-escaped (#245 contract). The + # hostile file path still appears raw inside an ``_inline_code`` + # span in the title โ€” code spans are the sanctioned literal + # carrier; a Markdown converter escapes their content itself. + assert "<img" not in md.lower() + assert "<img" in md.lower() + assert "<script>alert(1)</script> hostile title" not in md + assert "<script>alert(1)</script> hostile title" in md diff --git a/test/test_data/artifact_tool.jsonl b/test/test_data/artifact_tool.jsonl new file mode 100644 index 00000000..30a42adb --- /dev/null +++ b/test/test_data/artifact_tool.jsonl @@ -0,0 +1,11 @@ +{"type":"user","uuid":"u-root","parentUuid":null,"timestamp":"2026-07-02T10:00:00.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":"publish the report as an artifact"}} +{"type":"assistant","uuid":"a-artifact-html","parentUuid":"u-root","timestamp":"2026-07-02T10:00:01.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-1","model":"claude-fable-5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_artifact_html","name":"Artifact","input":{"file_path":"/workspace/demo/flaky-tests-report.html","favicon":"๐Ÿ“Š","description":"Dashboard of flaky tests found in the CI sweep.","label":"first-sweep"}}]}} +{"type":"user","uuid":"u-artifact-html-result","parentUuid":"a-artifact-html","timestamp":"2026-07-02T10:00:02.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_test_artifact_html","content":"Published /workspace/demo/flaky-tests-report.html at https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555"}]},"toolUseResult":{"url":"https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555","path":"/workspace/demo/flaky-tests-report.html","title":"Flaky tests report"}} +{"type":"assistant","uuid":"a-artifact-md","parentUuid":"u-artifact-html-result","timestamp":"2026-07-02T10:00:03.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-2","model":"claude-fable-5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_artifact_md","name":"Artifact","input":{"file_path":"/workspace/demo/notes.md","favicon":"๐Ÿ“"}}]}} +{"type":"user","uuid":"u-artifact-md-result","parentUuid":"a-artifact-md","timestamp":"2026-07-02T10:00:04.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_test_artifact_md","content":"Published /workspace/demo/notes.md at https://claude.ai/code/artifact/66666666-7777-8888-9999-000000000000"}]},"toolUseResult":{"url":"https://claude.ai/code/artifact/66666666-7777-8888-9999-000000000000","path":"/workspace/demo/notes.md","title":"notes.md"}} +{"type":"assistant","uuid":"a-artifact-redeploy","parentUuid":"u-artifact-md-result","timestamp":"2026-07-02T10:00:05.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-3","model":"claude-fable-5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_artifact_redeploy","name":"Artifact","input":{"file_path":"/workspace/demo/flaky-tests-report.html","favicon":"๐Ÿ“Š","description":"Dashboard of flaky tests found in the CI sweep.","label":"after-fixes","url":"https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555","force":true}}]}} +{"type":"user","uuid":"u-artifact-redeploy-result","parentUuid":"a-artifact-redeploy","timestamp":"2026-07-02T10:00:06.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_test_artifact_redeploy","content":"Published /workspace/demo/flaky-tests-report.html at https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555"}]},"toolUseResult":{"url":"https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555","path":"/workspace/demo/flaky-tests-report.html","title":"Flaky tests report"}} +{"type":"assistant","uuid":"a-artifact-denied","parentUuid":"u-artifact-redeploy-result","timestamp":"2026-07-02T10:00:07.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-4","model":"claude-fable-5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_artifact_denied","name":"Artifact","input":{"file_path":"/workspace/demo/private-notes.md","favicon":"๐Ÿ”’"}}]}} +{"type":"user","uuid":"u-artifact-denied-result","parentUuid":"a-artifact-denied","timestamp":"2026-07-02T10:00:08.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_test_artifact_denied","content":"Permission for this action was denied by the Claude Code auto mode classifier. Reason: [Create Public Surface] This Artifact action publishes a page to claude.ai (an external service).","is_error":true}]}} +{"type":"assistant","uuid":"a-artifact-xss","parentUuid":"u-artifact-denied-result","timestamp":"2026-07-02T10:00:09.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-5","model":"claude-fable-5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_artifact_xss","name":"Artifact","input":{"file_path":"/workspace/demo/<script>alert(1)</script>.html","favicon":"๐Ÿงช","description":"<img src=x onerror=alert(1)> hostile description","label":"<b>bold</b>","url":"javascript:alert(1)"}}]}} +{"type":"user","uuid":"u-artifact-xss-result","parentUuid":"a-artifact-xss","timestamp":"2026-07-02T10:00:10.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_test_artifact_xss","content":"Published /workspace/demo/<script>alert(1)</script>.html at https://claude.ai/code/artifact/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}]},"toolUseResult":{"url":"javascript:alert(1)","path":"/workspace/demo/<script>alert(1)</script>.html","title":"<script>alert(1)</script> hostile title"}} From e5db628b240f33b6eee03e132ae784fd74bf1e5a Mon Sep 17 00:00:00 2001 From: Christian Boos <christian.boos@bct-technology.com> Date: Thu, 2 Jul 2026 20:28:56 +0200 Subject: [PATCH 2/3] Silently skip 'frame-link' (Artifact bookkeeping) and 'mode' entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A successful Artifact publish also appends a uuid-less {type: 'frame-link', path, frameUrl, timestamp} entry mapping the source file to the deployed claude.ai page โ€” fully redundant with the Artifact tool_result, so skip it without the 'unrecognized message type' warning. 'mode' ({mode: 'normal'|...}) joins the same session-state-snapshot family as 'permission-mode'. The silent-skip warn-once test now uses a made-up type as its unknown example since 'mode' is recognized. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- claude_code_log/converter.py | 6 ++++++ test/test_artifact_rendering.py | 9 +++++++++ test/test_data/artifact_tool.jsonl | 1 + test/test_silent_skip.py | 8 ++++---- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/claude_code_log/converter.py b/claude_code_log/converter.py index 4a7ff9ef..90960818 100644 --- a/claude_code_log/converter.py +++ b/claude_code_log/converter.py @@ -95,9 +95,15 @@ def _dag_warnings_suppressed(silent: bool) -> Iterator[None]: # transcript; see #94 for the wider "propagate this state to # surrounding messages" follow-up. "permission-mode", # {permissionMode: 'acceptEdits'|...} + "mode", # {mode: 'normal'|...} "custom-title", # {customTitle: <str>} "agent-name", # {agentName: <str>} "agent-color", # {agentColor: <str>} + # Written alongside a successful Artifact publish (#257): + # {path, frameUrl, timestamp} maps the source file to the deployed + # claude.ai page. No uuid; fully redundant with the Artifact + # tool_result (same path and URL), which is rendered. + "frame-link", } ) diff --git a/test/test_artifact_rendering.py b/test/test_artifact_rendering.py index d3516873..3d49ba1e 100644 --- a/test/test_artifact_rendering.py +++ b/test/test_artifact_rendering.py @@ -366,6 +366,15 @@ def test_html_end_to_end(self): assert "๐Ÿ–ผ๏ธ" in html assert "๐Ÿ› ๏ธ ๐Ÿ–ผ๏ธ" not in html + def test_frame_link_entry_skipped_silently(self, capsys): + """The ``frame-link`` entry Claude Code writes alongside a + successful publish is bookkeeping (path โ†’ deployed URL, no uuid, + redundant with the tool_result) โ€” it must be skipped without an + 'unrecognized message type' warning.""" + load_transcript(self.FIXTURE) + captured = capsys.readouterr() + assert "unrecognized message type" not in captured.out + def test_html_end_to_end_xss(self): """The hostile entry's payloads must never survive unescaped.""" messages = load_transcript(self.FIXTURE) diff --git a/test/test_data/artifact_tool.jsonl b/test/test_data/artifact_tool.jsonl index 30a42adb..43472601 100644 --- a/test/test_data/artifact_tool.jsonl +++ b/test/test_data/artifact_tool.jsonl @@ -1,6 +1,7 @@ {"type":"user","uuid":"u-root","parentUuid":null,"timestamp":"2026-07-02T10:00:00.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":"publish the report as an artifact"}} {"type":"assistant","uuid":"a-artifact-html","parentUuid":"u-root","timestamp":"2026-07-02T10:00:01.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-1","model":"claude-fable-5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_artifact_html","name":"Artifact","input":{"file_path":"/workspace/demo/flaky-tests-report.html","favicon":"๐Ÿ“Š","description":"Dashboard of flaky tests found in the CI sweep.","label":"first-sweep"}}]}} {"type":"user","uuid":"u-artifact-html-result","parentUuid":"a-artifact-html","timestamp":"2026-07-02T10:00:02.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_test_artifact_html","content":"Published /workspace/demo/flaky-tests-report.html at https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555"}]},"toolUseResult":{"url":"https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555","path":"/workspace/demo/flaky-tests-report.html","title":"Flaky tests report"}} +{"type":"frame-link","sessionId":"s1","path":"/workspace/demo/flaky-tests-report.html","frameUrl":"https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555","timestamp":"2026-07-02T10:00:02.500Z"} {"type":"assistant","uuid":"a-artifact-md","parentUuid":"u-artifact-html-result","timestamp":"2026-07-02T10:00:03.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-2","model":"claude-fable-5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_artifact_md","name":"Artifact","input":{"file_path":"/workspace/demo/notes.md","favicon":"๐Ÿ“"}}]}} {"type":"user","uuid":"u-artifact-md-result","parentUuid":"a-artifact-md","timestamp":"2026-07-02T10:00:04.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_test_artifact_md","content":"Published /workspace/demo/notes.md at https://claude.ai/code/artifact/66666666-7777-8888-9999-000000000000"}]},"toolUseResult":{"url":"https://claude.ai/code/artifact/66666666-7777-8888-9999-000000000000","path":"/workspace/demo/notes.md","title":"notes.md"}} {"type":"assistant","uuid":"a-artifact-redeploy","parentUuid":"u-artifact-md-result","timestamp":"2026-07-02T10:00:05.000Z","sessionId":"s1","version":"2.1.198","cwd":"/tmp","userType":"external","isSidechain":false,"message":{"id":"msg-3","model":"claude-fable-5","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_test_artifact_redeploy","name":"Artifact","input":{"file_path":"/workspace/demo/flaky-tests-report.html","favicon":"๐Ÿ“Š","description":"Dashboard of flaky tests found in the CI sweep.","label":"after-fixes","url":"https://claude.ai/code/artifact/11111111-2222-3333-4444-555555555555","force":true}}]}} diff --git a/test/test_silent_skip.py b/test/test_silent_skip.py index b892d9de..8e1eec06 100644 --- a/test/test_silent_skip.py +++ b/test/test_silent_skip.py @@ -189,11 +189,11 @@ def test_repeated_unknown_type_warns_only_once( _write_jsonl( jsonl, [ - {"type": "mode", "payload": 1}, + {"type": "not-yet-known", "payload": 1}, {"type": "pr-link", "url": "x"}, - {"type": "mode", "payload": 2}, + {"type": "not-yet-known", "payload": 2}, {"type": "pr-link", "url": "y"}, - {"type": "mode", "payload": 3}, + {"type": "not-yet-known", "payload": 3}, ], ) @@ -203,7 +203,7 @@ def test_repeated_unknown_type_warns_only_once( assert messages == [] # One warning per distinct type, not per occurrence. assert captured.out.count("unrecognized message type") == 2 - assert captured.out.count("'mode'") == 1 + assert captured.out.count("'not-yet-known'") == 1 assert captured.out.count("'pr-link'") == 1 def test_silent_mode_suppresses_warning( From c47d39c30cde8dd9be236eb0f714205b7b9ec2af Mon Sep 17 00:00:00 2001 From: Christian Boos <christian.boos@bct-technology.com> Date: Fri, 3 Jul 2026 12:21:13 +0200 Subject: [PATCH 3/3] Extract shared is_safe_web_url scheme gate; surface it for plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on #260: - The http(s) scheme whitelist was implemented twice (_artifact_link in html/tool_formatters.py, _markdown_safe_url in markdown/renderer.py). Extract is_safe_web_url() into claude_code_log/utils.py (format-neutral, import-cycle-free) and delegate both call sites; the policy โ€” including its deliberate fail-closed case-sensitivity โ€” now lives in one place. - Re-export it from claude_code_log.plugins (_PUBLIC_HELPERS + lazy PEP-562 resolution + TYPE_CHECKING import) and document it in dev-docs/plugins.md ยง4.1/ยง4.2: a plugin emitting a transcript-derived URL as an href / Markdown link target needs the same gate, since escaping can't neutralise a hostile javascript: scheme. - Fix the ArtifactInput docstring: only file_path is required by the model; favicon is required by the live tool schema but defaulted here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- claude_code_log/html/tool_formatters.py | 10 +++++----- claude_code_log/markdown/renderer.py | 18 ++++++++++++------ claude_code_log/models.py | 9 +++++---- claude_code_log/plugins.py | 10 ++++++++++ claude_code_log/utils.py | 17 +++++++++++++++++ dev-docs/plugins.md | 9 ++++++--- test/test_plugin_xss_api.py | 14 ++++++++++++++ 7 files changed, 69 insertions(+), 18 deletions(-) diff --git a/claude_code_log/html/tool_formatters.py b/claude_code_log/html/tool_formatters.py index 650f5db1..21bb6fab 100644 --- a/claude_code_log/html/tool_formatters.py +++ b/claude_code_log/html/tool_formatters.py @@ -34,7 +34,7 @@ render_user_markdown_collapsible, resolve_memory_body_links, ) -from ..utils import strip_error_tags +from ..utils import is_safe_web_url, strip_error_tags from ..workflow import resolve_workflow_header, resolve_workflow_script from ..models import ( AskUserQuestionInput, @@ -865,12 +865,12 @@ def format_webfetch_output(output: WebFetchOutput) -> str: def _artifact_link(url: str) -> str: """Render an artifact URL as a link, or as escaped text for odd schemes. - The URL is transcript-derived and thus attacker-controllable; escaping - alone doesn't neutralise a hostile scheme (``javascript:`` survives - ``escape_html`` intact), so only emit an ``href`` for http(s) URLs. + The URL is transcript-derived and thus attacker-controllable; the + shared ``is_safe_web_url`` scheme gate decides link-worthiness + (escaping alone doesn't neutralise a hostile ``javascript:`` scheme). """ escaped = escape_html(url) - if url.startswith(("https://", "http://")): + if is_safe_web_url(url): return f'<a href="{escaped}">{escaped}</a>' return escaped diff --git a/claude_code_log/markdown/renderer.py b/claude_code_log/markdown/renderer.py index ad80c2f2..2a18a417 100644 --- a/claude_code_log/markdown/renderer.py +++ b/claude_code_log/markdown/renderer.py @@ -19,7 +19,12 @@ memory_short_path, render_user_markdown, ) -from ..utils import format_timestamp, generate_unified_diff, strip_error_tags +from ..utils import ( + format_timestamp, + generate_unified_diff, + is_safe_web_url, + strip_error_tags, +) from ..models import ( AssistantTextMessage, AwaySummaryMessage, @@ -309,13 +314,14 @@ def safe_markdown_inline(text: str) -> str: def _markdown_safe_url(url: str) -> str: """Render a transcript-derived URL as a Markdown link, or inline code. - Only http(s) URLs become clickable โ€” escaping alone doesn't neutralise - a hostile scheme (``javascript:`` would survive into a live link once a - downstream viewer converts the Markdown to HTML). The link target gets - its parentheses percent-encoded so it can't break out of the ``(url)`` + Only URLs passing the shared ``is_safe_web_url`` scheme gate become + clickable โ€” escaping alone doesn't neutralise a hostile scheme + (``javascript:`` would survive into a live link once a downstream + viewer converts the Markdown to HTML). The link target gets its + parentheses percent-encoded so it can't break out of the ``(url)`` destination syntax. """ - if url.startswith(("https://", "http://")): + if is_safe_web_url(url): target = url.replace("(", "%28").replace(")", "%29") return f"[{safe_markdown_inline(url)}]({target})" return _inline_code(url) diff --git a/claude_code_log/models.py b/claude_code_log/models.py index 194dce40..8fca1011 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -1486,10 +1486,11 @@ class ArtifactInput(BaseModel): """Input parameters for the Artifact tool. Deploys a self-contained HTML or Markdown file as a (default-private) - claude.ai web page. Only ``file_path`` and ``favicon`` are required in - the current schema; keep the rest optional and tolerate unknown fields - so schema tweaks across Claude Code versions don't drop the message to - the generic params table. + claude.ai web page. Only ``file_path`` is required here โ€” the live tool + schema also requires ``favicon``, but it's defaulted so variant + transcripts still parse. Keep the rest optional and tolerate unknown + fields so schema tweaks across Claude Code versions don't drop the + message to the generic params table. """ file_path: str diff --git a/claude_code_log/plugins.py b/claude_code_log/plugins.py index c915e188..38ba005c 100644 --- a/claude_code_log/plugins.py +++ b/claude_code_log/plugins.py @@ -45,6 +45,7 @@ render_markdown_collapsible, ) from .markdown.renderer import safe_markdown_inline + from .utils import is_safe_web_url from .models import MessageContent, MessageMeta @@ -357,6 +358,11 @@ def apply_transformers( # ``dev-docs/plugins.md`` ยง4 "Security-conscious rendering". "escape_html", "safe_markdown_inline", + # Scheme gate for emitting transcript-derived URLs as links (#257): + # escaping can't neutralise a hostile ``javascript:`` scheme, so a + # plugin building an ``href`` / Markdown link target needs the same + # http(s) whitelist the core renderers use. + "is_safe_web_url", } ) @@ -370,6 +376,9 @@ def __getattr__(name: str) -> Any: # PEP 562 # lazily to keep package init acyclic. if name == "safe_markdown_inline": from .markdown.renderer import safe_markdown_inline as resolved + elif name == "is_safe_web_url": + # Format-neutral (shared by both renderers) โ†’ top-level utils. + from .utils import is_safe_web_url as resolved else: from .html import utils as _utils @@ -384,6 +393,7 @@ def __getattr__(name: str) -> Any: # PEP 562 "MessageTransformer", "apply_transformers", "escape_html", + "is_safe_web_url", "load_transformers", "render_markdown", "render_markdown_collapsible", diff --git a/claude_code_log/utils.py b/claude_code_log/utils.py index b79a101b..639aa825 100644 --- a/claude_code_log/utils.py +++ b/claude_code_log/utils.py @@ -692,6 +692,23 @@ def get_warmup_session_ids(messages: list[TranscriptEntry]) -> set[str]: return warmup_sessions +def is_safe_web_url(url: str) -> bool: + """True if ``url`` is a plain-web (http/https) URL safe to emit as a link. + + The single scheme policy for every renderer surface that turns a + transcript-derived URL into something clickable (an HTML ``href`` or a + Markdown ``[text](target)`` destination). Escaping alone doesn't + neutralise a hostile scheme โ€” ``javascript:alert(1)`` survives + entity-escaping intact โ€” so callers must gate on this whitelist and + degrade anything it rejects to inert text. + + Deliberately case-sensitive: wire URLs are lowercase, and an unmatched + ``HTTP://`` merely fails closed (not clickable), which is the right + direction for a security gate. + """ + return url.startswith(("https://", "http://")) + + def strip_error_tags(text: str) -> str: """Strip <tool_use_error>...</tool_use_error> tags, keeping content. diff --git a/dev-docs/plugins.md b/dev-docs/plugins.md index eb492008..e0f47b44 100644 --- a/dev-docs/plugins.md +++ b/dev-docs/plugins.md @@ -283,11 +283,11 @@ moves to the next ancestor. ### 4.1 Plugin-facing helpers -Four helpers are re-exported from `claude_code_log.plugins` for use +Five helpers are re-exported from `claude_code_log.plugins` for use in `format_html` / `format_markdown` / `title` methods. The re-export is the stable plugin API; the underlying implementation (in -`claude_code_log/html/utils.py` and `claude_code_log/markdown/renderer.py`) -may move or be renamed. +`claude_code_log/html/utils.py`, `claude_code_log/markdown/renderer.py` +and `claude_code_log/utils.py`) may move or be renamed. ```python from claude_code_log.plugins import ( @@ -295,6 +295,7 @@ from claude_code_log.plugins import ( render_markdown_collapsible, escape_html, safe_markdown_inline, + is_safe_web_url, ) ``` @@ -304,6 +305,7 @@ from claude_code_log.plugins import ( | `render_markdown_collapsible(raw_content, css_class, *, line_threshold=20, preview_line_count=5)` | `(str, str, int, int) -> str` | Long Markdown bodies (mail bodies, agent responses, multi-paragraph result text). Returns inline `<div class="{css_class} markdown">โ€ฆ</div>` for short content, a collapsible `<details>` with preview + full body for content exceeding `line_threshold`. Escapes raw HTML. | | `escape_html(text)` | `(str) -> str` | Interpolating transcript-derived text into a `format_html` raw-HTML string, OR into a `title` return (which is emitted via `\| safe`). Entity-escapes `<`, `>`, `&`, quotes. | | `safe_markdown_inline(text)` | `(str) -> str` | Interpolating transcript-derived text into a Markdown **inline** fragment in `format_markdown` (a link label, a list item, inline prose) โ€” the Markdown-output path emits `format_markdown` verbatim. Entity-escapes raw HTML tags while preserving Markdown formatting. | +| `is_safe_web_url(url)` | `(str) -> bool` | Gating a transcript-derived URL before making it clickable โ€” an `href` in `format_html` or a `[text](target)` destination in `format_markdown`. Escaping cannot neutralise a hostile scheme (`javascript:alert(1)` survives `escape_html` intact), so emit the link only when this returns True and degrade to inert escaped text otherwise. http(s) whitelist, fails closed. | The reference plugin's [`tool_communicate_result.py`](../test/_plugins/clmail/src/claude_code_log_clmail_test/transformers/tool_communicate_result.py) @@ -335,6 +337,7 @@ output paths: | `format_html` embedding a Markdown body | `render_markdown(value)` / `render_markdown_collapsible(value, โ€ฆ)` | | `format_markdown` inline fragment (link label, list item, inline prose) | `safe_markdown_inline(value)` | | `title()` return | `escape_html(value)` โ€” the HTML header emits it via `\| safe` (no core escaping); the Markdown heading is auto-gated by the core | +| URL made clickable (`href` attribute, Markdown link target) | gate on `is_safe_web_url(url)` first, **then** `escape_html(url)` for the attribute text โ€” the scheme check and the escaping guard different attacks | Notes: diff --git a/test/test_plugin_xss_api.py b/test/test_plugin_xss_api.py index 1ba83cc6..b45ce0db 100644 --- a/test/test_plugin_xss_api.py +++ b/test/test_plugin_xss_api.py @@ -34,6 +34,7 @@ def test_helpers_importable_and_in_all(self): "render_markdown_collapsible", "escape_html", "safe_markdown_inline", + "is_safe_web_url", ): assert hasattr(plugins, name), name assert name in plugins.__all__, name @@ -46,6 +47,19 @@ def test_safe_markdown_inline_behaviour(self): # Tag-free text passes through unchanged (no markdown re-normalisation). assert safe_markdown_inline("a **bold** label") == "a **bold** label" + def test_is_safe_web_url_behaviour(self): + from claude_code_log.plugins import is_safe_web_url + + assert is_safe_web_url("https://example.com/page") + assert is_safe_web_url("http://example.com") + # Hostile or odd schemes fail closed โ€” including case variants. + assert not is_safe_web_url("javascript:alert(1)") + assert not is_safe_web_url("JAVASCRIPT:alert(1)") + assert not is_safe_web_url("data:text/html,<script>alert(1)</script>") + assert not is_safe_web_url("file:///etc/passwd") + assert not is_safe_web_url("HTTP://example.com") # fails closed, by design + assert not is_safe_web_url("") + # ----------------------------- plugin-author contract ------------------------