ProvenPin Install

Jinja2 SandboxedEnvironment for untrusted user templates: working versions (tested 2026-10-16)

Render untrusted user templates with a hardened sandbox: allowlisted globals, an immutable context, strict undefined handling and an output size cap.

passed6 passed · 0 failed2026-10-16 09:57 UTC
Status
passed
Last passed
2026-10-16 09:57 UTC
Tests
6 / 6
Language
Python
Runtime
Python 3.13.16
pinned versions2
jinja2@3.1.6pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38883899161

The code

Exactly the files that passed. Your agent can pull them with get_recipe("jinja2-sandboxed-user-templates") (install), or see them on GitHub.

requirements.txt
jinja2==3.1.6
pytest==9.1.1
renderer.py
from jinja2 import StrictUndefined, TemplateError
from jinja2.sandbox import ImmutableSandboxedEnvironment, safe_range


class OutputTooLargeError(TemplateError):
    """Raised when a rendered template exceeds the configured size cap."""


class SafeRenderer:
    """Render untrusted user templates with a locked-down Jinja2 sandbox."""

    def __init__(self, allowed_globals=None, max_output=100_000):
        # Immutable sandbox blocks in-place mutation of list/dict/set passed in.
        self.env = ImmutableSandboxedEnvironment(
            undefined=StrictUndefined,
            autoescape=True,
        )
        # Drop Jinja's default globals (lipsum, cycler, dict, ...): allowlist only.
        self.env.globals = {"range": safe_range}
        self.env.globals.update(allowed_globals or {})
        self.max_output = max_output

    def render(self, source, context=None):
        template = self.env.from_string(source)
        parts = []
        size = 0
        # generate() is lazy, so the cap stops a runaway loop mid-render.
        for chunk in template.generate(**(context or {})):
            size += len(chunk)
            if size > self.max_output:
                raise OutputTooLargeError(
                    f"rendered output exceeded {self.max_output} characters"
                )
            parts.append(chunk)
        return "".join(parts)
tests/test_renderer.py
import pytest
from jinja2 import UndefinedError
from jinja2.sandbox import SecurityError, SandboxedEnvironment
from renderer import OutputTooLargeError, SafeRenderer


def test_renders_with_allowlisted_globals_and_escapes():
    r = SafeRenderer(allowed_globals={"site": "ProvenPin"})
    out = r.render("Hello {{ name }} on {{ site }}", {"name": "<b>Sam</b>"})
    assert out == "Hello &lt;b&gt;Sam&lt;/b&gt; on ProvenPin"


def test_default_sandbox_silently_hides_unsafe_access():
    # Gotcha: a plain SandboxedEnvironment returns an undefined that prints as
    # "", so unsafe attribute access fails open instead of raising.
    raw = SandboxedEnvironment()
    assert raw.from_string("{{ name.__class__ }}").render(name="x") == ""

    with pytest.raises(SecurityError):
        SafeRenderer().render("{{ name.__class__ }}", {"name": "x"})


def test_str_format_escape_is_neutralized():
    # str.format can walk attributes ("{0.__class__}") and is a classic escape.
    with pytest.raises(SecurityError):
        SafeRenderer().render("{{ '{0.__class__}'.format(name) }}", {"name": "x"})


def test_immutable_sandbox_blocks_mutation_but_base_does_not():
    items = [1, 2]
    with pytest.raises(SecurityError):
        SafeRenderer().render("{{ items.append(3) }}", {"items": items})
    assert items == [1, 2]

    # The plain sandbox happily mutates the caller's list.
    SandboxedEnvironment().from_string("{{ items.append(3) }}").render(items=items)
    assert items == [1, 2, 3]


def test_globals_are_allowlisted_and_range_is_capped():
    r = SafeRenderer()
    with pytest.raises(UndefinedError):
        r.render("{{ lipsum(1) }}")
    with pytest.raises(OverflowError):
        r.render("{{ range(200000)|length }}")
    assert r.render("{{ range(3)|join(',') }}") == "0,1,2"


def test_output_cap_stops_runaway_loop():
    r = SafeRenderer(max_output=1000)
    assert r.render("{% for i in range(3) %}x{% endfor %}") == "xxx"
    with pytest.raises(OutputTooLargeError):
        r.render("{% for i in range(100000) %}x{% endfor %}")
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.