diff --git a/application/agents/tools/artifact_generator.py b/application/agents/tools/artifact_generator.py index 18f5dc18..31203402 100644 --- a/application/agents/tools/artifact_generator.py +++ b/application/agents/tools/artifact_generator.py @@ -1,7 +1,7 @@ """Artifact Generator tool: render editable documents from a JSON spec and version them append-only. The ``artifact_versions.spec`` JSONB is the source of truth; the rendered -``.pptx``/``.docx``/``.xlsx``/``.pdf`` is derived. ``create_artifact`` stores +``.pptx``/``.docx``/``.xlsx``/``.pdf``/``.html`` is derived. ``create_artifact`` stores v1, ``edit_artifact`` applies an RFC 7386 merge-patch to the current spec and appends a version, ``rewrite_artifact`` replaces the spec wholesale and appends a version. Rendering runs a FIXED program in the sandbox that reads the spec as @@ -58,6 +58,11 @@ _KIND_INFO: Dict[str, Dict[str, str]] = { "ext": "pdf", "mime": "application/pdf", }, + "html": { + "kind": "html", + "ext": "html", + "mime": "text/html", + }, } # Tight per-kind JSON schemas. ``additionalProperties: false`` keeps specs minimal @@ -144,6 +149,72 @@ _SCHEMAS: Dict[str, Dict[str, Any]] = { }, "required": ["blocks"], }, + "html": { + "type": "object", + "additionalProperties": False, + "properties": { + "title": {"type": "string"}, + "blocks": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": False, + "properties": { + "type": {"const": "heading"}, + "text": {"type": "string"}, + "level": {"type": "integer", "minimum": 1, "maximum": 3}, + }, + "required": ["type", "text"], + }, + { + "type": "object", + "additionalProperties": False, + "properties": { + "type": {"const": "paragraph"}, + "text": {"type": "string"}, + }, + "required": ["type", "text"], + }, + { + "type": "object", + "additionalProperties": False, + "properties": { + "type": {"const": "list"}, + "ordered": {"type": "boolean"}, + "items": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["type", "items"], + }, + { + "type": "object", + "additionalProperties": False, + "properties": { + "type": {"const": "table"}, + "headers": {"type": "array", "items": {"type": "string"}}, + "rows": { + "type": "array", + "items": {"type": "array", "items": {"type": "string"}}, + }, + }, + "required": ["type", "rows"], + }, + { + "type": "object", + "additionalProperties": False, + "properties": { + "type": {"const": "code"}, + "text": {"type": "string"}, + }, + "required": ["type", "text"], + }, + ], + }, + }, + }, + "required": ["blocks"], + }, } # FIXED renderer programs. Each reads ``spec.json`` from the workspace as DATA @@ -227,6 +298,66 @@ _RENDERERS: Dict[str, str] = { " story.append(Spacer(1, 6))\n" "SimpleDocTemplate({out_path!r}, pagesize=letter).build(story)\n" ), + "html": ( + "import json\n" + "import html\n" + "spec = json.load(open({spec_path!r}))\n" + "def esc(value):\n" + " return html.escape('' if value is None else str(value))\n" + "parts = []\n" + "title = spec.get('title')\n" + "if title:\n" + " parts.append('

' + esc(title) + '

')\n" + "for block in spec.get('blocks', []):\n" + " kind = block.get('type')\n" + " if kind == 'heading':\n" + " level = block.get('level') or 2\n" + " try:\n" + " level = int(level)\n" + " except (TypeError, ValueError):\n" + " level = 2\n" + " level = min(max(level, 1), 3) + 1\n" + " parts.append('%s' % (level, esc(block.get('text', '')), level))\n" + " elif kind == 'paragraph':\n" + " parts.append('

' + esc(block.get('text', '')) + '

')\n" + " elif kind == 'list':\n" + " tag = 'ol' if block.get('ordered') else 'ul'\n" + " items = ''.join('
  • ' + esc(i) + '
  • ' for i in (block.get('items') or []))\n" + " parts.append('<%s>%s' % (tag, items, tag))\n" + " elif kind == 'table':\n" + " rows_html = []\n" + " headers = block.get('headers')\n" + " if headers:\n" + " cells = ''.join('' + esc(h) + '' for h in headers)\n" + " rows_html.append('' + cells + '')\n" + " body = []\n" + " for row in (block.get('rows') or []):\n" + " cells = ''.join('' + esc(c) + '' for c in row)\n" + " body.append('' + cells + '')\n" + " rows_html.append('' + ''.join(body) + '')\n" + " parts.append('' + ''.join(rows_html) + '
    ')\n" + " elif kind == 'code':\n" + " parts.append('
    ' + esc(block.get('text', '')) + '
    ')\n" + # CSS braces are doubled so the outer ``_RENDERERS[kind].format(...)`` leaves them literal. + "css = (\n" + " 'body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;'\n" + " 'line-height:1.6;color:#1a1a1a;max-width:800px;margin:0 auto;padding:24px}}'\n" + " 'h1,h2,h3,h4{{line-height:1.25;margin:1.2em 0 0.5em}}'\n" + " 'table{{border-collapse:collapse;width:100%;margin:1em 0}}'\n" + " 'th,td{{border:1px solid #d0d0d0;padding:6px 10px;text-align:left}}'\n" + " 'th{{background:#f5f5f5}}'\n" + " 'pre{{background:#f5f5f5;padding:12px;border-radius:6px;overflow:auto}}'\n" + " 'code{{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}}'\n" + ")\n" + "doc = (\n" + " ''\n" + " ''\n" + " '' + esc(title or 'Report') + ''\n" + " ''\n" + " + ''.join(parts) + ''\n" + ")\n" + "open({out_path!r}, 'w', encoding='utf-8').write(doc)\n" + ), } @@ -247,7 +378,7 @@ def merge_patch(target: Any, patch: Any) -> Any: class ArtifactGeneratorTool(Tool): """Artifact Generator - Render editable documents (presentation/document/spreadsheet/pdf) from a JSON spec and version them. + Render editable documents (presentation/document/spreadsheet/pdf/html) from a JSON spec and version them. """ def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None: @@ -277,7 +408,13 @@ class ArtifactGeneratorTool(Tool): "parameters": { "type": "object", "properties": { - "kind": {"type": "string", "enum": kinds, "description": "Document kind to render."}, + "kind": { + "type": "string", + "enum": kinds, + "description": ( + "Document kind to render; `html` is an inline-rendered, versionable HTML report." + ), + }, "title": {"type": "string", "description": "Optional artifact title."}, "spec": {"type": "object", "description": "Document spec matching the kind's schema."}, }, @@ -373,7 +510,7 @@ class ArtifactGeneratorTool(Tool): return {"status": "error", "error": rendered["error"]} info = _KIND_INFO[kind] - filename = self._filename(title, info["ext"]) + filename = self._filename(title, kind) try: ref = persist_new_artifact( user_id=self.user_id, @@ -422,7 +559,7 @@ class ArtifactGeneratorTool(Tool): if rendered.get("error"): return {"status": "error", "error": rendered["error"]} info = _KIND_INFO[kind] - filename = self._filename(None, info["ext"]) + filename = self._filename(None, kind) try: ref = append_artifact_version( user_id=self.user_id, @@ -565,10 +702,12 @@ class ArtifactGeneratorTool(Tool): } @staticmethod - def _filename(title: Optional[str], ext: str) -> str: + def _filename(title: Optional[str], kind: str) -> str: """Derive a download filename from a title (or a generic stem) plus the kind extension.""" + if kind == "html": + return "report.html" stem = (title or "artifact").strip() or "artifact" - return f"{stem}.{ext}" + return f"{stem}.{_KIND_INFO[kind]['ext']}" def _resolve_session_id(self) -> Optional[str]: """Derive the sandbox session id from the bound conversation/run; sanitize to the gateway charset.""" diff --git a/tests/agents/tools/test_artifact_generator_unit.py b/tests/agents/tools/test_artifact_generator_unit.py index 5bcd6c00..116f5c1c 100644 --- a/tests/agents/tools/test_artifact_generator_unit.py +++ b/tests/agents/tools/test_artifact_generator_unit.py @@ -158,3 +158,84 @@ def test_pdf_renderer_escapes_markup_and_does_not_execute(): spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]} out_path = _render_in_process("pdf", spec) assert os.path.getsize(out_path) > 0 + + +# --------------------------------------------------------------------------- +# html kind: schema, renderer injection safety, output escaping +# --------------------------------------------------------------------------- + + +def test_validate_accepts_html_spec(): + spec = { + "title": "Report", + "blocks": [ + {"type": "heading", "text": "Intro", "level": 2}, + {"type": "paragraph", "text": "Body."}, + {"type": "list", "ordered": True, "items": ["a", "b"]}, + {"type": "table", "headers": ["h"], "rows": [["c"]]}, + {"type": "code", "text": "print(1)"}, + ], + } + assert _tool()._validate("html", spec) is None + + +def test_validate_rejects_html_unknown_block_key(): + err = _tool()._validate("html", {"blocks": [{"type": "paragraph", "text": "x", "bogus": 1}]}) + assert err["status"] == "error" + + +def test_validate_rejects_html_bad_heading_level(): + err = _tool()._validate("html", {"blocks": [{"type": "heading", "text": "x", "level": 9}]}) + assert err["status"] == "error" + + +def test_html_renderer_does_not_execute_spec_code(capfd, tmp_path): + # The renderer json.loads the spec as data; a value that is Python source + # must never run. If the program string-interpolated the spec it would. + sentinel = tmp_path / "pwned.txt" + payload = ( + f"'''__import__('os').system('echo PWNED > {sentinel}')'''; " + "print('SHOULD_NOT_PRINT')" + ) + spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]} + out_path = _render_in_process("html", spec) + + captured = capfd.readouterr() + assert "SHOULD_NOT_PRINT" not in captured.out + assert "PWNED" not in captured.out + assert not sentinel.exists() + assert os.path.getsize(out_path) > 0 + + +def test_html_renderer_escapes_spec_markup(): + # Spec text carrying live markup must appear HTML-ESCAPED in the output, so + # no raw " + img = '' + spec = { + "title": script, + "blocks": [ + {"type": "paragraph", "text": img}, + {"type": "heading", "text": "h", "level": 1}, + {"type": "list", "items": ["x"]}, + {"type": "table", "headers": ["th"], "rows": [["td"]]}, + {"type": "code", "text": ""}, + ], + } + with open(_render_in_process("html", spec), encoding="utf-8") as handle: + html_doc = handle.read() + + # No live tag from spec content survives. + assert "" not in html_doc + assert "h" not in html_doc + assert "x" not in html_doc + assert "th" not in html_doc + assert "td" not in html_doc + # The escaped forms are present instead. + assert "<script>alert(1)</script>" in html_doc + assert "<img src=x onerror=" in html_doc + # The fixed structural HTML the renderer emits is intact. + assert "" in html_doc + assert "

    " in html_doc and "

    " in html_doc + assert "" in html_doc and "
    " in html_doc
    diff --git a/tests/integration/test_artifact_generator_e2e.py b/tests/integration/test_artifact_generator_e2e.py
    index 3bcc12bc..25987d8c 100644
    --- a/tests/integration/test_artifact_generator_e2e.py
    +++ b/tests/integration/test_artifact_generator_e2e.py
    @@ -211,6 +211,65 @@ def test_create_spreadsheet_xlsx(wired_tool):
         assert ws.cell(row=2, column=2).value == 2
     
     
    +def test_create_edit_html_versions(wired_tool):
    +    tool, conversation_id, pg_engine, storage = wired_tool
    +
    +    # create -> v1: an html report rendered to report.html (kind html, text/html).
    +    spec = {
    +        "title": "Findings ",
    +        "blocks": [
    +            {"type": "heading", "text": "Overview", "level": 2},
    +            {"type": "paragraph", "text": "Body ."},
    +            {"type": "list", "ordered": False, "items": ["one", "two"]},
    +            {"type": "table", "headers": ["k", "v"], "rows": [["a", "1"]]},
    +        ],
    +    }
    +    created = tool.execute_action("create_artifact", kind="html", title="Report", spec=spec)
    +    assert created["status"] == "ok", created
    +    assert created["version"] == 1
    +    assert created["mime_type"] == "text/html"
    +    assert created["filename"] == "report.html"
    +    assert created["ref"] == "A1"
    +    artifact_id = created["artifact_id"]
    +
    +    with pg_engine.connect() as conn:
    +        repo = ArtifactsRepository(conn)
    +        artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=conversation_id)
    +        v1 = repo.get_version(artifact_id, 1)
    +    assert artifact["kind"] == "html"
    +    assert artifact["current_version"] == 1
    +    assert v1["spec"] == spec
    +    assert v1["produced_by"]["spec_kind"] == "html"
    +
    +    # The stored bytes are a valid HTML doc with escaped title/blocks (no live tag).
    +    html_doc = storage.get_file(v1["storage_path"]).read().decode("utf-8")
    +    assert html_doc.startswith("")
    +    assert "Findings <hi>" in html_doc
    +    assert "<script>alert(1)</script>" in html_doc
    +    assert "" not in html_doc
    +    assert "
  • one
  • " in html_doc and "
    " in html_doc + + # edit (merge-patch) -> v2: replace the title, keep v1 intact. + edited = tool.execute_action("edit_artifact", id="A1", spec_patch={"title": "Updated"}) + assert edited["status"] == "ok", edited + assert edited["version"] == 2 + assert edited["artifact_id"] == artifact_id + assert edited["ref"] == "A1" + + with pg_engine.connect() as conn: + repo = ArtifactsRepository(conn) + artifact = repo.get_artifact_in_parent(artifact_id, conversation_id=conversation_id) + v1_again = repo.get_version(artifact_id, 1) + v2 = repo.get_version(artifact_id, 2) + assert artifact["current_version"] == 2 + # Append-only: v1 unchanged; v2 carries the merged spec re-rendered. + assert v1_again["spec"] == spec + assert v2["spec"]["title"] == "Updated" + assert len(v2["spec"]["blocks"]) == 4 + v2_doc = storage.get_file(v2["storage_path"]).read().decode("utf-8") + assert "

    Updated

    " in v2_doc + + def test_invalid_spec_creates_no_artifact(wired_tool): tool, conversation_id, pg_engine, _storage = wired_tool
    a