fix(session-export): escape html tool call names

This commit is contained in:
Koho Zheng 2026-07-10 06:05:24 +08:00 committed by Teknium
parent b5c655c89e
commit a4dd08a977
2 changed files with 67 additions and 13 deletions

View file

@ -8,7 +8,9 @@ Enhanced with UI-UX-PRO-MAX design intelligence.
import json
import datetime
import secrets
from typing import Any, Dict, List
from urllib.parse import quote
# --- Icons (Lucide-style SVGs) ---
ICON_USER = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
@ -26,6 +28,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-{script_nonce}'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'">
<title>{page_title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -566,13 +569,13 @@ HTML_TEMPLATE = """<!DOCTYPE html>
</div>
</div>
<script>
<script nonce="{script_nonce}">
// Session Switching
function showSession(id) {{
// Update Sidebar
document.querySelectorAll('.session-item').forEach(el => el.classList.remove('active'));
const activeItem = document.querySelector(`.session-item[data-id="${{id}}"]`);
if (activeItem) activeItem.classList.add('active');
document.querySelectorAll('.session-item').forEach(el => {{
el.classList.toggle('active', el.dataset.id === id);
}});
// Update View
document.querySelectorAll('.session-view').forEach(el => {{
@ -582,9 +585,16 @@ HTML_TEMPLATE = """<!DOCTYPE html>
if (activeView) activeView.classList.add('active');
// Store in URL hash
window.location.hash = id;
window.location.hash = encodeURIComponent(id);
}}
document.querySelectorAll('.session-item').forEach(item => {{
item.addEventListener('click', (e) => {{
e.preventDefault();
showSession(item.dataset.id || '');
}});
}});
// Search Filter
const searchInput = document.getElementById('session-search');
if (searchInput) {{
@ -611,7 +621,7 @@ HTML_TEMPLATE = """<!DOCTYPE html>
// Initialization
window.addEventListener('load', () => {{
const hash = window.location.hash.slice(1);
const hash = decodeURIComponent(window.location.hash.slice(1));
if (hash) {{
showSession(hash);
}} else {{
@ -761,16 +771,17 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
if is_multi:
sidebar_items = []
for s in sessions:
sid = s.get("id", "N/A")
sid = str(s.get("id", "N/A"))
escaped_sid = _escape_html(sid)
title = s.get("title") or s.get("preview") or "Untitled Session"
if len(title) > 50: title = title[:47] + "..."
date = _format_timestamp(s.get("started_at", 0)).split(" ")[0]
item = f'''
<a class="session-item" data-id="{sid}" onclick="showSession('{sid}')">
<a class="session-item" data-id="{escaped_sid}" href="#{quote(sid, safe='')}">
<div class="session-item-title">{_escape_html(title)}</div>
<div class="session-item-meta">
<span>{sid[:8]}</span>
<span>{_escape_html(sid[:8])}</span>
<span>{date}</span>
</div>
</a>
@ -797,7 +808,8 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
# Main Content
sessions_html_list = []
for s in sessions:
sid = s.get("id", "N/A")
sid = str(s.get("id", "N/A"))
escaped_sid = _escape_html(sid)
title = s.get("title") or "Hermes Session"
model = s.get("model", "Unknown")
started_at = _format_timestamp(s.get("started_at", 0))
@ -808,7 +820,7 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
view_class = "session-view"
if not is_multi: view_class += " active"
session_view_id = f"view-{sid}"
session_view_id = f"view-{escaped_sid}"
system_prompt = s.get("system_prompt")
system_html = ""
@ -830,7 +842,7 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
<header class="fade-in">
<h1>{_escape_html(title)}</h1>
<div class="meta">
<div class="meta-item"><strong>ID:</strong> {sid}</div>
<div class="meta-item"><strong>ID:</strong> {escaped_sid}</div>
<div class="meta-item"><strong>Model:</strong> {_escape_html(model)}</div>
<div class="meta-item"><strong>Started:</strong> {started_at}</div>
</div>
@ -843,13 +855,15 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
'''
sessions_html_list.append(session_html)
script_nonce = secrets.token_urlsafe(16)
return HTML_TEMPLATE.format(
page_title="Hermes Session Export" if is_multi else _escape_html(sessions[0].get("title", "Hermes Session")),
sidebar_html=sidebar_html,
sessions_html="\n".join(sessions_html_list),
main_margin="var(--sidebar-width)" if is_multi else "0",
layout_class="layout-multi" if is_multi else "layout-single",
generated_at=generated_at
generated_at=generated_at,
script_nonce=script_nonce,
)
def generate_html_export(session_data: Dict[str, Any]) -> str:

View file

@ -2,6 +2,10 @@ import json
import sys
from hermes_cli.session_export import export_record_count, render_sessions_export
from hermes_cli.session_export_html import (
_generate_messages_html,
generate_multi_session_html_export,
)
def _sample_session():
@ -113,6 +117,42 @@ def test_full_markdown_renderer_collapses_tool_output_and_filters_system():
assert "hidden system context" not in rendered
def test_html_export_escapes_tool_call_names():
payload = '<img src=x onerror="alert(document.domain)">'
rendered = _generate_messages_html(
[
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": payload, "arguments": "<b>x</b>"},
}
],
}
]
)
assert payload not in rendered
assert '&lt;img src=x onerror=&quot;alert(document.domain)&quot;&gt;' in rendered
assert "&lt;b&gt;x&lt;/b&gt;" in rendered
def test_html_export_uses_csp_without_inline_event_handlers():
first = _sample_session()
second = {**_sample_session(), "id": "sess-456", "title": "Second session"}
rendered = generate_multi_session_html_export([first, second])
assert "Content-Security-Policy" in rendered
assert "script-src 'nonce-" in rendered
assert "<script nonce=" in rendered
assert "onclick=" not in rendered
def test_export_record_count_switches_unit_for_prompt_only_exports():
assert export_record_count([_sample_session()]) == (1, "session")
assert export_record_count([_sample_session()], only="user-prompts") == (