Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions claude_code_log/factories/tool_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1646,6 +1646,18 @@ def create_tool_result_message(
tool_use_result,
)

# The Artifact result line shows the favicon and version label, but the
# wire result carries only {url, path, title} — carry both over from the
# paired tool_use input (issue #262). Missing pairing degrades to the
# title/url fallbacks in the formatters.
if isinstance(parsed_output, ArtifactOutput):
artifact_use = tool_use_context.get(tool_result.tool_use_id)
if artifact_use is not None:
favicon = artifact_use.input.get("favicon")
label = artifact_use.input.get("label")
parsed_output.favicon = favicon if isinstance(favicon, str) else None
parsed_output.label = label if isinstance(label, str) else None

# An AskUserQuestion "clarify" rejection arrives as an ``is_error`` result,
# but we re-present it as a normal answered card (questions + the user's
# free-form reply), so drop the error framing (#180).
Expand Down
9 changes: 9 additions & 0 deletions claude_code_log/html/templates/components/message_styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1794,3 +1794,12 @@ a.workflow-phase-pill:hover {
color: var(--text-muted);
font-family: var(--font-monospace);
}

/* Artifact tool result (issue #262): "Published artifact (favicon) (label)".
The favicon is usually an emoji span; when it is a real image URL it
renders as an inline <img> sized to the text line. */
img.artifact-favicon {
height: 1em;
width: 1em;
vertical-align: -0.125em;
}
106 changes: 71 additions & 35 deletions claude_code_log/html/tool_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
render_user_markdown_collapsible,
resolve_memory_body_links,
)
from ..utils import is_safe_web_url, strip_error_tags
from ..utils import ARTIFACT_FAVICON_TEXT_MAX, is_safe_web_url, strip_error_tags
from ..workflow import resolve_workflow_header, resolve_workflow_script
from ..models import (
AskUserQuestionInput,
Expand Down Expand Up @@ -862,27 +862,37 @@ def format_webfetch_output(output: WebFetchOutput) -> str:
# -- Artifact Tool ------------------------------------------------------------


def _artifact_link(url: str) -> str:
"""Render an artifact URL as a link, or as escaped text for odd schemes.
def _artifact_labeled_link(url: str, text: str) -> str:
"""Link ``text`` to ``url``, keeping the URL in href + hover only.

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).
(escaping alone doesn't neutralise a hostile ``javascript:``
scheme). A rejected URL degrades to the escaped URL itself, not the
label — hiding it would mask what the transcript contains (#262).
"""
escaped = escape_html(url)
if is_safe_web_url(url):
return f'<a href="{escaped}">{escaped}</a>'
return escaped
escaped_url = escape_html(url)
return (
f'<a class="artifact-link" href="{escaped_url}"'
f' title="{escaped_url}">{escape_html(text)}</a>'
)
return escape_html(url)


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.
"""Format Artifact tool use content — description + redeploy meta.

The source file path is shown in the title. The favicon and version
label belong to the published page, so the tool result line carries
them (issue #262); the description stays primary, with a meta row
only for the redeploy addendum: the target URL and the ``force``
flag. ``force`` means "overwrite without a conflict check" — a
normal redeploy sends the artifact's base version so a concurrent
write from another session fails instead of being silently
clobbered; ``force`` skips that check. 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:
Expand All @@ -891,41 +901,67 @@ def format_artifact_input(artifact_input: ArtifactInput) -> str:
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>'
f'<span class="artifact-redeploy">redeploys '
f"{_artifact_labeled_link(artifact_input.url, 'existing artifact')}</span>"
)
if artifact_input.force:
meta_parts.append('<span class="artifact-force">force</span>')
meta_parts.append(
'<span class="artifact-force" title="A normal redeploy fails on a'
" concurrent edit from another session; force overwrites it"
'">force: overwrite without conflict check</span>'
)
if meta_parts:
rows.append(f'<div class="artifact-meta">{" · ".join(meta_parts)}</div>')
return "".join(rows)


def _artifact_favicon(favicon: str) -> str:
"""Render the artifact favicon — a real image when one exists.

The value is transcript-derived and thus attacker-controllable: only
an ``is_safe_web_url`` http(s) URL may become an ``<img src>`` (a
``data:``/``javascript:`` src or an attribute breakout is an XSS
vector — the src is escaped and the scheme gated). Anything else is
treated as the usual emoji favicon and rendered as escaped text;
over-long non-URL values are dropped rather than echoed.
``referrerpolicy="no-referrer"`` keeps the local transcript page out
of the remote host's logs.
"""
if is_safe_web_url(favicon):
return (
f'<img class="artifact-favicon" src="{escape_html(favicon)}"'
f' alt="" referrerpolicy="no-referrer">'
)
if len(favicon) <= ARTIFACT_FAVICON_TEXT_MAX:
return f'<span class="artifact-favicon">{escape_html(favicon)}</span>'
return ""


def format_artifact_output(output: ArtifactOutput) -> str:
"""Format Artifact tool result — published page title + link.
"""Format Artifact tool result — 'Published artifact (favicon) (label)'.

Falls back to the escaped raw text for error/denial results that
carry no structured data.
The label (falling back to the page title, then the source file
basename) is the clickable link; the full URL stays in ``href`` and
the hover ``title`` instead of the visible text (issue #262). 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>"
parts: list[str] = ["Published artifact"]
if output.favicon:
favicon_html = _artifact_favicon(output.favicon)
if favicon_html:
parts.append(favicon_html)
link_text = (
output.label
or output.title
or (output.path.rsplit("/", 1)[-1] if output.path else "")
or output.url
)
parts.append(_artifact_labeled_link(output.url, link_text))
return f'<div class="artifact-output">{" ".join(parts)}</div>'
if output.raw_text:
return f'<div class="artifact-output">{escape_html(output.raw_text)}</div>'
return ""
Expand Down
111 changes: 89 additions & 22 deletions claude_code_log/markdown/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
render_user_markdown,
)
from ..utils import (
ARTIFACT_FAVICON_TEXT_MAX,
format_timestamp,
generate_unified_diff,
is_safe_web_url,
Expand Down Expand Up @@ -311,19 +312,55 @@ def safe_markdown_inline(text: str) -> str:
return _protect_html_tags(text) if "<" in text else text


def _markdown_safe_url(url: str) -> str:
_MARKDOWN_TARGET_ENCODINGS = (
("(", "%28"), # destination-syntax breakout
(")", "%29"),
('"', "%22"), # attribute breakout after Markdown→HTML conversion
("'", "%27"),
("<", "%3C"),
(">", "%3E"),
(" ", "%20"), # terminates the destination early
)


def safe_markdown_link_target(url: str) -> str:
"""Percent-encode characters that could escape a ``(target)`` slot.

Parentheses break the destination syntax itself; quotes/angle
brackets could smuggle attributes or tags through a downstream
Markdown→HTML converter; a space — or, per CommonMark, any ASCII
control character (U+0000-U+001F, U+007F) — terminates an
un-bracketed destination early, letting trailing text escape the
link.

Plugin-facing (re-exported from ``claude_code_log.plugins``): any
plugin emitting a transcript-derived URL as a Markdown link/image
destination needs this in ADDITION to the ``is_safe_web_url`` scheme
gate — the gate rejects hostile schemes, this neutralises breakout
characters inside an accepted URL.
"""
for char, encoded in _MARKDOWN_TARGET_ENCODINGS:
url = url.replace(char, encoded)
return "".join(
f"%{ord(char):02X}" if ord(char) <= 0x1F or ord(char) == 0x7F else char
for char in url
)


def _markdown_safe_url(url: str, text: Optional[str] = None) -> 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.
viewer converts the Markdown to HTML). The link target is
percent-encoded against destination breakout. ``text`` labels the
link instead of the URL itself; a hostile-scheme URL falls back to
inline code regardless, so the hostile string stays visible rather
than hidden behind a label.
"""
if is_safe_web_url(url):
target = url.replace("(", "%28").replace(")", "%29")
return f"[{safe_markdown_inline(url)}]({target})"
return f"[{safe_markdown_inline(text if text else url)}]({safe_markdown_link_target(url)})"
return _inline_code(url)


Expand Down Expand Up @@ -1099,24 +1136,24 @@ def format_WebFetchInput(self, input: WebFetchInput, _: TemplateMessage) -> str:
return ""

def format_ArtifactInput(self, input: ArtifactInput, _: TemplateMessage) -> str:
"""Format → description + favicon/label/redeploy meta lines.
"""Format → description + redeploy meta line.

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``.
The favicon and version label belong to the published page, so
the tool result line carries them (issue #262); the meta line
keeps only the redeploy addendum — target URL and the ``force``
flag (= overwrite without the concurrent-edit conflict check).
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)}")
meta_parts.append(
f"redeploys {_markdown_safe_url(input.url, text='existing artifact')}"
)
if input.force:
meta_parts.append("force")
meta_parts.append("force: overwrite without conflict check")
if meta_parts:
parts.append(" · ".join(meta_parts))
return "\n\n".join(parts)
Expand Down Expand Up @@ -1676,20 +1713,50 @@ def format_WebFetchOutput(self, output: WebFetchOutput, _: TemplateMessage) -> s
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
"""Format → 'Published artifact (favicon) (label link)', or quoted
raw text.

Mirrors the HTML renderer (issue #262): the label (falling back
to title, then source basename) is the link text, keeping the
full URL out of the visible line; an http(s) favicon value
becomes a Markdown image, an emoji one stays inline 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 ""
parts: list[str] = ["Published artifact"]
if output.favicon:
favicon_md = self._artifact_favicon_md(output.favicon)
if favicon_md:
parts.append(favicon_md)
link_text = (
output.label
or output.title
or (output.path.rsplit("/", 1)[-1] if output.path else "")
or None
)
return f"Published {title_part}{_markdown_safe_url(output.url)}"
parts.append(_markdown_safe_url(output.url, text=link_text))
return " ".join(parts)
if output.raw_text:
return self._quote(output.raw_text)
return ""

@staticmethod
def _artifact_favicon_md(favicon: str) -> str:
"""Favicon for the published-artifact line — image or inline text.

Same gate as the HTML renderer: only an ``is_safe_web_url``
http(s) value may become an image source (a hostile scheme would
go live once a downstream viewer converts the Markdown); emoji
values render as inert inline text, over-long non-URL values are
dropped.
"""
if is_safe_web_url(favicon):
return f"![]({safe_markdown_link_target(favicon)})"
if len(favicon) <= ARTIFACT_FAVICON_TEXT_MAX:
return safe_markdown_inline(favicon)
return ""

# --- Teammate-feature tool outputs -------------------------------------

def format_TeamCreateOutput(
Expand Down
4 changes: 4 additions & 0 deletions claude_code_log/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2073,6 +2073,10 @@ class ArtifactOutput:
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
# Carried over from the paired tool_use input (the wire result has no
# favicon/label) so the result line can show them (issue #262).
favicon: Optional[str] = None
label: Optional[str] = None


# =============================================================================
Expand Down
19 changes: 13 additions & 6 deletions claude_code_log/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
render_markdown,
render_markdown_collapsible,
)
from .markdown.renderer import safe_markdown_inline
from .markdown.renderer import safe_markdown_inline, safe_markdown_link_target
from .utils import is_safe_web_url

from .models import MessageContent, MessageMeta
Expand Down Expand Up @@ -363,19 +363,25 @@ def apply_transformers(
# plugin building an ``href`` / Markdown link target needs the same
# http(s) whitelist the core renderers use.
"is_safe_web_url",
# Companion to the scheme gate for Markdown destinations (#262): an
# ACCEPTED http(s) URL can still carry quotes/angle-brackets/spaces
# that break out of the ``(target)`` slot or smuggle attributes
# through a downstream Markdown->HTML conversion.
"safe_markdown_link_target",
}
)


def __getattr__(name: str) -> Any: # PEP 562
if name in _PUBLIC_HELPERS:
# ``safe_markdown_inline`` (the markdown renderer's inline HTML-
# neutralising gate — entity-escapes raw HTML tags in an inline
# markdown fragment, preserving markdown) lives in
# ``markdown/renderer.py``; the others in ``html/utils.py``. Resolved
# lazily to keep package init acyclic.
# The ``safe_markdown_*`` pair (inline HTML-neutralising gate +
# link-target percent-encoder) lives in ``markdown/renderer.py``;
# ``is_safe_web_url`` in top-level ``utils.py``; the others in
# ``html/utils.py``. Resolved lazily to keep package init acyclic.
if name == "safe_markdown_inline":
from .markdown.renderer import safe_markdown_inline as resolved
elif name == "safe_markdown_link_target":
from .markdown.renderer import safe_markdown_link_target 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
Expand All @@ -399,4 +405,5 @@ def __getattr__(name: str) -> Any: # PEP 562
"render_markdown_collapsible",
"reset_cache",
"safe_markdown_inline",
"safe_markdown_link_target",
]
Loading
Loading