Add document generation tool with editable spec-backed artifacts

Add an artifact generator tool with create, edit, and rewrite actions that
render presentations, documents, spreadsheets, and PDFs from a validated JSON
spec by running a fixed program in the sandbox (the spec travels as data, so
its contents can never execute). The spec is stored as the source of truth on
each artifact version; edits apply a JSON merge-patch and re-render, appending
a new version while earlier versions stay intact.
This commit is contained in:
Alex committed 2026-06-24 12:30:18 +01:00
1 parent 1ac1f793b7
commit 381fb3335d
5 files changed
+1006 -2

No files matched your search

@@ -0,0 +1,160 @@
"""Unit tests for ArtifactGeneratorTool: spec validation, merge-patch, and renderer injection safety.
No DB or sandbox: these exercise the pure logic (schema gate + RFC 7386 merge)
and the FIXED renderer programs directly (executed in-process against a temp dir)
to prove a spec value containing Python/quotes is rendered as literal text.
"""
from __future__ import annotations
import json
import os
import tempfile
import pytest
from application.agents.tools.artifact_generator import (
_KIND_INFO,
_RENDERERS,
ArtifactGeneratorTool,
merge_patch,
)
pytest.importorskip("pptx")
pytest.importorskip("docx")
pytest.importorskip("openpyxl")
pytest.importorskip("reportlab")
def _tool():
return ArtifactGeneratorTool(
tool_config={"conversation_id": "conv-1", "tool_id": "t-1"}, user_id="u-1"
)
# ---------------------------------------------------------------------------
# Spec validation
# ---------------------------------------------------------------------------
def test_validate_accepts_minimal_presentation():
assert _tool()._validate("presentation", {"slides": [{"title": "x"}]}) is None
def test_validate_rejects_missing_required_key():
err = _tool()._validate("presentation", {"title": "no slides"})
assert err["status"] == "error"
assert "invalid presentation spec" in err["error"]
def test_validate_rejects_unknown_key():
err = _tool()._validate("document", {"sections": [], "bogus": 1})
assert err["status"] == "error"
def test_validate_rejects_wrong_type():
err = _tool()._validate("spreadsheet", {"sheets": "not-a-list"})
assert err["status"] == "error"
def test_validate_rejects_non_object_spec():
err = _tool()._validate("pdf", "just a string")
assert err["status"] == "error"
assert "spec must be a JSON object" in err["error"]
def test_create_rejects_unknown_kind():
out = _tool()._create(kind="hologram", spec={})
assert out["status"] == "error"
assert "unsupported kind" in out["error"]
# ---------------------------------------------------------------------------
# RFC 7386 JSON merge-patch
# ---------------------------------------------------------------------------
def test_merge_patch_adds_and_overwrites():
assert merge_patch({"a": 1, "b": 2}, {"b": 3, "c": 4}) == {"a": 1, "b": 3, "c": 4}
def test_merge_patch_null_deletes_key():
assert merge_patch({"a": 1, "b": 2}, {"b": None}) == {"a": 1}
def test_merge_patch_recurses_into_objects():
assert merge_patch({"x": {"a": 1, "b": 2}}, {"x": {"b": None, "c": 3}}) == {"x": {"a": 1, "c": 3}}
def test_merge_patch_replaces_array_wholesale():
# RFC 7386: arrays are replaced, not merged element-wise.
assert merge_patch({"l": [1, 2, 3]}, {"l": [9]}) == {"l": [9]}
def test_merge_patch_non_object_patch_replaces_target():
assert merge_patch({"a": 1}, "scalar") == "scalar"
def test_merge_patch_does_not_mutate_inputs():
target = {"a": {"b": 1}}
patch = {"a": {"c": 2}}
merge_patch(target, patch)
assert target == {"a": {"b": 1}}
assert patch == {"a": {"c": 2}}
# ---------------------------------------------------------------------------
# Renderer injection safety
# ---------------------------------------------------------------------------
def _render_in_process(kind: str, spec: dict) -> str:
"""Execute the FIXED renderer program against a temp dir; return the output path."""
workdir = tempfile.mkdtemp()
spec_path = os.path.join(workdir, "spec.json")
out_path = os.path.join(workdir, f"out.{_KIND_INFO[kind]['ext']}")
with open(spec_path, "w") as handle:
json.dump(spec, handle)
program = _RENDERERS[kind].format(spec_path=spec_path, out_path=out_path)
namespace: dict = {}
exec(compile(program, "<renderer>", "exec"), namespace, namespace) # noqa: S102
return out_path
def test_renderer_does_not_execute_spec_code(capfd, tmp_path):
# A spec whose values are Python source / shell payloads must be treated as
# literal text. If the renderer string-interpolated the spec it would run
# this; instead it json.loads the spec as data, so nothing executes.
sentinel = tmp_path / "pwned.txt"
payload = (
f"'''__import__('os').system('echo PWNED > {sentinel}')'''; "
"print('SHOULD_NOT_PRINT')"
)
spec = {
"title": payload,
"slides": [{"title": payload, "bullets": [payload], "notes": payload}],
}
out_path = _render_in_process("presentation", 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_renderer_keeps_injection_text_as_literal_content():
from pptx import Presentation
payload = "'''; import os; os.system('echo HACK'); x = '''"
spec = {"slides": [{"title": payload, "bullets": [payload]}]}
prs = Presentation(_render_in_process("presentation", spec))
assert len(prs.slides) == 1
assert prs.slides[0].shapes.title.text == payload
def test_pdf_renderer_escapes_markup_and_does_not_execute():
payload = "<b>not bold</b> & \"</para>\" '''os.system('x')'''"
spec = {"title": payload, "blocks": [{"type": "paragraph", "text": payload}]}
out_path = _render_in_process("pdf", spec)
assert os.path.getsize(out_path) > 0