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: } "agent-name", # {agentName: } "agent-color", # {agentColor: } + # 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/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 at ``. +# Fallback for entries that lack the structured toolUseResult. +_ARTIFACT_PUBLISHED_RE = re.compile( + r"^Published (?P.+) at (?Phttps?://\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 + ```` 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..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, @@ -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; 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 is_safe_web_url(url): + 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..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, @@ -68,6 +73,7 @@ ToolUseContent, SkillInput, WebSearchInput, + ArtifactInput, WebFetchInput, WorkflowAgentMessage, WorkflowPhaseMessage, @@ -100,6 +106,7 @@ TeamDeleteOutput, ToolResultContent, WebSearchOutput, + ArtifactOutput, WebFetchOutput, WriteOutput, ) @@ -304,6 +311,22 @@ 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 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 is_safe_web_url(url): + 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 +1098,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 +1675,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 +1994,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..8fca1011 100644 --- a/claude_code_log/models.py +++ b/claude_code_log/models.py @@ -1482,6 +1482,27 @@ 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`` 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 + 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 +1687,7 @@ class WorkflowToolInput(BaseModel): ExitPlanModeInput, WebSearchInput, WebFetchInput, + ArtifactInput, MonitorInput, ScheduleWakeupInput, CronCreateInput, @@ -2037,6 +2059,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 +2214,7 @@ class TaskStopOutput: ExitPlanModeOutput, WebSearchOutput, WebFetchOutput, + ArtifactOutput, MonitorOutput, ScheduleWakeupOutput, CronCreateOutput, 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/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/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_artifact_rendering.py b/test/test_artifact_rendering.py new file mode 100644 index 00000000..3d49ba1e --- /dev/null +++ b/test/test_artifact_rendering.py @@ -0,0 +1,402 @@ +#!/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_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) + 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..43472601 --- /dev/null +++ b/test/test_data/artifact_tool.jsonl @@ -0,0 +1,12 @@ +{"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}}]}} +{"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"}} 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 ------------------------ 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(