diff --git a/src/ra/viewer/html.py b/src/ra/viewer/html.py index ef29e89..16a69b9 100644 --- a/src/ra/viewer/html.py +++ b/src/ra/viewer/html.py @@ -16,7 +16,9 @@ import json from dataclasses import dataclass +from html import escape from pathlib import Path +import re from ra.viewer import style from ra.viewer.trace import RunTrace, load_rollout_dir @@ -168,8 +170,12 @@ class Panel: def trace_panel() -> Panel: """The built-in causal-trace panel, reusable by any ``ra`` agent.""" - return Panel("trace", "Trace", _TRACE_SECTION, _TRACE_CSS, _TRACE_JS, "renderTrace();") + return Panel( + "trace", "Trace", _TRACE_SECTION, _TRACE_CSS, _TRACE_JS, "renderTrace();" + ) + +_SHELL_PLACEHOLDER_RE = re.compile(r"__[A-Z_]+__") _SHELL = r""" @@ -254,17 +260,20 @@ def render_page( render_calls = "\n ".join(p.render_call for p in panels) panels_meta = json.dumps([{"id": p.id, "label": p.label} for p in panels]) default = json.dumps(default_view or (panels[0].id if panels else "")) - return ( - _SHELL.replace("__TITLE__", f"{brand} — run view") - .replace("__BRAND__", brand) - .replace("__STYLE__", css) - .replace("__SECTIONS__", sections) - .replace("__PANELS_META__", panels_meta) - .replace("__PANEL_JS__", panel_js) - .replace("__RENDER_CALLS__", render_calls) - .replace("__DEFAULT_VIEW__", default) - .replace("__DATA__", blob) - ) + escaped_brand = escape(brand, quote=True) + escaped_title = escape(f"{brand} — run view", quote=True) + replacements = { + "__TITLE__": escaped_title, + "__BRAND__": escaped_brand, + "__STYLE__": css, + "__SECTIONS__": sections, + "__PANELS_META__": panels_meta, + "__PANEL_JS__": panel_js, + "__RENDER_CALLS__": render_calls, + "__DEFAULT_VIEW__": default, + "__DATA__": blob, + } + return _SHELL_PLACEHOLDER_RE.sub(lambda match: replacements[match.group(0)], _SHELL) def render_trace_html(run: RunTrace, *, brand: str = "ra") -> str: diff --git a/tests/test_ra_viewer.py b/tests/test_ra_viewer.py index 90e7bfb..a04f324 100644 --- a/tests/test_ra_viewer.py +++ b/tests/test_ra_viewer.py @@ -17,12 +17,31 @@ def _write_rollout(dir_path: Path) -> None: rollouts = dir_path / "rollouts" rollouts.mkdir() rows = [ - {"type": "metadata", "agent": "root", "depth": 0, "spawn_id": "r1", - "timestamp": "2026-06-03T00:00:00+00:00", "model": "some/model"}, - {"type": "iteration", "agent": "root", "iteration": 1, "spawn_id": "r1", - "timestamp": "2026-06-03T00:01:00+00:00", "response": "thinking", "code_blocks": []}, - {"type": "result", "agent": "root", "iteration": 1, "spawn_id": "r1", - "timestamp": "2026-06-03T00:02:00+00:00", "final_answer": "done"}, + { + "type": "metadata", + "agent": "root", + "depth": 0, + "spawn_id": "r1", + "timestamp": "2026-06-03T00:00:00+00:00", + "model": "some/model", + }, + { + "type": "iteration", + "agent": "root", + "iteration": 1, + "spawn_id": "r1", + "timestamp": "2026-06-03T00:01:00+00:00", + "response": "thinking", + "code_blocks": [], + }, + { + "type": "result", + "agent": "root", + "iteration": 1, + "spawn_id": "r1", + "timestamp": "2026-06-03T00:02:00+00:00", + "final_answer": "done", + }, ] (rollouts / "root.jsonl").write_text( "\n".join(json.dumps(r) for r in rows), encoding="utf-8" @@ -41,19 +60,53 @@ def test_render_trace_html_is_self_contained(tmp_path: Path) -> None: assert "renderTrace();" in html +def test_render_page_escapes_brand_in_shell_chrome() -> None: + # Regression: brand is a public render_page/render_trace_html input, so it + # must not be able to break out of the or header markup. + panel = Panel( + id="trace", + label="Trace", + section='<section class="view" id="view-trace"></section>', + css="", + js="", + render_call="", + ) + brand = '__DATA__

' + data = {"title": '', "run": {}} + + html = render_page(data, [panel], brand=brand) + + assert '' not in html + assert '' not in html + assert "__DATA__</title><script>alert" in html + + def test_render_page_composes_arbitrary_panels() -> None: run = RunTrace( - title="t", benchmark=None, task_id="t", success=None, failure_reason=None, - poc_source=None, models=["m"], agents=[], root_name="root", - root_result=None, root_steps=[], unlinked=[], + title="t", + benchmark=None, + task_id="t", + success=None, + failure_reason=None, + poc_source=None, + models=["m"], + agents=[], + root_name="root", + root_result=None, + root_steps=[], + unlinked=[], ) custom = Panel( - id="notes", label="Notes", + id="notes", + label="Notes", section='

', - css=".notes{}", js="function renderNotes(){document.getElementById('n').textContent='hi';}", + css=".notes{}", + js="function renderNotes(){document.getElementById('n').textContent='hi';}", render_call="renderNotes();", ) - html = render_page({"title": "t", "run": run.as_dict()}, [custom], default_view="notes") + html = render_page( + {"title": "t", "run": run.as_dict()}, [custom], default_view="notes" + ) assert 'id="view-notes"' in html assert "renderNotes();" in html assert '"id": "notes"' in html or '"id":"notes"' in html