From 094c58ba03ffd22ead632f93bdf1ca23006e9a4b Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 10:13:04 -0400 Subject: [PATCH 01/13] feat: add RunConfig jinja rendering engine - add a RunConfig enum/field that selects native Jinja by default while preserving ginja as an opt-in hardened mode - route shared prompt and sampler rendering through the selected engine instead of hardcoding ginja behavior - cover the new selection path with config, engine, and docs updates Refs #87 Refs #550 Signed-off-by: Eric W. Tramel --- docs/code_reference/run_config.md | 4 +- .../src/data_designer/config/__init__.py | 3 +- .../src/data_designer/config/run_config.py | 20 ++++ .../tests/config/test_run_config.py | 15 +++ .../generators/llm_completion.py | 1 + .../column_generators/generators/samplers.py | 1 + .../utils/prompt_renderer.py | 10 +- .../engine/processing/ginja/environment.py | 100 ++++++++++++++++-- .../engine/sampling_gen/generator.py | 8 +- .../engine/sampling_gen/jinja_utils.py | 15 ++- .../generators/test_expression.py | 25 +++++ .../utils/test_prompt_renderer.py | 32 ++++++ .../engine/sampling_gen/test_jinja_utils.py | 18 ++++ 13 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 packages/data-designer-config/tests/config/test_run_config.py diff --git a/docs/code_reference/run_config.md b/docs/code_reference/run_config.md index 2b68f266c..f3ce4fb94 100644 --- a/docs/code_reference/run_config.md +++ b/docs/code_reference/run_config.md @@ -1,7 +1,8 @@ # Run Config The `run_config` module defines runtime settings that control dataset generation behavior, -including early shutdown thresholds, batch sizing, and non-inference worker concurrency. +including early shutdown thresholds, batch sizing, non-inference worker concurrency, +and the Jinja rendering engine used by the runtime. ## Usage @@ -13,6 +14,7 @@ data_designer = DataDesigner() data_designer.set_run_config(dd.RunConfig( buffer_size=500, max_conversation_restarts=3, + jinja_rendering_engine=dd.JinjaRenderingEngine.GINJA, )) ``` diff --git a/packages/data-designer-config/src/data_designer/config/__init__.py b/packages/data-designer-config/src/data_designer/config/__init__.py index ed3336259..eb385e15a 100644 --- a/packages/data-designer-config/src/data_designer/config/__init__.py +++ b/packages/data-designer-config/src/data_designer/config/__init__.py @@ -58,7 +58,7 @@ ProcessorType, SchemaTransformProcessorConfig, ) - from data_designer.config.run_config import RunConfig, ThrottleConfig # noqa: F401 + from data_designer.config.run_config import JinjaRenderingEngine, RunConfig, ThrottleConfig # noqa: F401 from data_designer.config.sampler_constraints import ( # noqa: F401 ColumnInequalityConstraint, ConstraintType, @@ -175,6 +175,7 @@ "ProcessorType": (_MOD_PROCESSORS, "ProcessorType"), "SchemaTransformProcessorConfig": (_MOD_PROCESSORS, "SchemaTransformProcessorConfig"), # run_config + "JinjaRenderingEngine": (f"{_MOD_BASE}.run_config", "JinjaRenderingEngine"), "RunConfig": (f"{_MOD_BASE}.run_config", "RunConfig"), "ThrottleConfig": (f"{_MOD_BASE}.run_config", "ThrottleConfig"), # sampler_constraints diff --git a/packages/data-designer-config/src/data_designer/config/run_config.py b/packages/data-designer-config/src/data_designer/config/run_config.py index c2e4269ec..6df8d30b5 100644 --- a/packages/data-designer-config/src/data_designer/config/run_config.py +++ b/packages/data-designer-config/src/data_designer/config/run_config.py @@ -9,6 +9,14 @@ from typing_extensions import Self from data_designer.config.base import ConfigBase +from data_designer.config.utils.type_helpers import StrEnum + + +class JinjaRenderingEngine(StrEnum): + """Template renderer used by the engine for user-supplied Jinja templates.""" + + NATIVE = "native" + GINJA = "ginja" class ThrottleConfig(ConfigBase): @@ -99,6 +107,11 @@ class RunConfig(ConfigBase): Default is False. progress_interval: How often (in seconds) the async progress reporter emits a consolidated log block. Must be > 0. Default is 5.0. + jinja_rendering_engine: Template renderer used for engine-side Jinja evaluation. + ``native`` uses Jinja2's built-in sandbox with the standard filter set and + fewer Data Designer-specific restrictions. ``ginja`` uses Data Designer's + hardened sandbox with additional AST, filter, and output guards. + Default is ``native``. throttle: AIMD throttle tuning parameters. See ``ThrottleConfig`` for details. """ @@ -112,6 +125,13 @@ class RunConfig(ConfigBase): async_trace: bool = False progress_bar: bool = False progress_interval: float = Field(default=5.0, gt=0.0) + jinja_rendering_engine: JinjaRenderingEngine = Field( + default=JinjaRenderingEngine.NATIVE, + description=( + "Template renderer used for engine-side Jinja evaluation. " + "`native` uses Jinja2's built-in sandbox; `ginja` uses Data Designer's hardened sandbox." + ), + ) throttle: ThrottleConfig = Field(default_factory=ThrottleConfig) @model_validator(mode="after") diff --git a/packages/data-designer-config/tests/config/test_run_config.py b/packages/data-designer-config/tests/config/test_run_config.py new file mode 100644 index 000000000..3d39cf5a0 --- /dev/null +++ b/packages/data-designer-config/tests/config/test_run_config.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from data_designer.config.run_config import JinjaRenderingEngine, RunConfig + + +def test_run_config_defaults_to_native_jinja_renderer() -> None: + assert JinjaRenderingEngine(RunConfig().jinja_rendering_engine) == JinjaRenderingEngine.NATIVE + + +def test_run_config_accepts_ginja_renderer() -> None: + run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.GINJA) + assert JinjaRenderingEngine(run_config.jinja_rendering_engine) == JinjaRenderingEngine.GINJA diff --git a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py index f6625f099..f0dbec4d9 100644 --- a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py +++ b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/llm_completion.py @@ -57,6 +57,7 @@ def prompt_renderer(self) -> RecordBasedPromptRenderer: "column_type": self.config.column_type, "model_alias": self.config.model_alias, }, + jinja_rendering_engine=self.resource_provider.run_config.jinja_rendering_engine, ) def generate(self, data: dict) -> dict: diff --git a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/samplers.py b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/samplers.py index 7f327239f..08c0170fc 100644 --- a/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/samplers.py +++ b/packages/data-designer-engine/src/data_designer/engine/column_generators/generators/samplers.py @@ -56,6 +56,7 @@ def _create_sampling_dataset_generator(self) -> SamplingDatasetGenerator: return SamplingDatasetGenerator( sampler_columns=self.config, person_generator_loader=(self._person_generator_loader if self._needs_person_generator else None), + jinja_rendering_engine=self.resource_provider.run_config.jinja_rendering_engine, ) def _log_person_generation_if_needed(self) -> None: diff --git a/packages/data-designer-engine/src/data_designer/engine/column_generators/utils/prompt_renderer.py b/packages/data-designer-engine/src/data_designer/engine/column_generators/utils/prompt_renderer.py index 2b19a6b3f..e0b085c67 100644 --- a/packages/data-designer-engine/src/data_designer/engine/column_generators/utils/prompt_renderer.py +++ b/packages/data-designer-engine/src/data_designer/engine/column_generators/utils/prompt_renderer.py @@ -9,6 +9,7 @@ from data_designer.config.base import SingleColumnConfig from data_designer.config.column_types import DataDesignerColumnType from data_designer.config.models import ModelConfig +from data_designer.config.run_config import JinjaRenderingEngine from data_designer.config.utils.code_lang import CodeLang from data_designer.config.utils.misc import extract_keywords_from_jinja2_template from data_designer.config.utils.type_helpers import StrEnum @@ -36,9 +37,16 @@ class PromptType(StrEnum): class RecordBasedPromptRenderer(WithJinja2UserTemplateRendering): - def __init__(self, response_recipe: ResponseRecipe, *, error_message_context: dict[str, str] | None = None): + def __init__( + self, + response_recipe: ResponseRecipe, + *, + error_message_context: dict[str, str] | None = None, + jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.NATIVE, + ): self.response_recipe = response_recipe self._error_message_context = error_message_context + self._jinja_rendering_engine = jinja_rendering_engine def render(self, *, prompt_template: str | None, record: dict, prompt_type: PromptType) -> str | None: self._prepare_environment(prompt_template=prompt_template, record=record, prompt_type=prompt_type) diff --git a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py index 98470d67d..10f8d32d9 100644 --- a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py +++ b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py @@ -15,6 +15,7 @@ from jinja2.sandbox import ImmutableSandboxedEnvironment from jsonpath_rust_bindings import Finder +from data_designer.config.run_config import JinjaRenderingEngine from data_designer.engine.processing.ginja.ast import ( ast_count_name_references, ast_descendant_count, @@ -369,6 +370,18 @@ def safe_render( return rendered_text + def render_template( + self, + user_template: str, + record: dict, + skip_template_validation: bool = False, + ) -> str: + return self.safe_render( + user_template, + record, + skip_template_validation=skip_template_validation, + ) + def get_references(self, user_template: str) -> set[str]: """Get all referenced variables from the provided template. @@ -384,6 +397,57 @@ def get_references(self, user_template: str) -> set[str]: return meta.find_undeclared_variables(ast) +class NativeJinjaSandboxEnvironment(ImmutableSandboxedEnvironment): + """Jinja2's built-in sandbox with Data Designer's reference whitelist.""" + + allowed_references: list[str] + _prefer_dict_key_access: bool + + def __init__( + self, + allowed_references: list[str] | None = None, + prefer_dict_key_access: bool = False, + **kwargs, + ): + super().__init__(autoescape=False, **kwargs) + self.allowed_references = allowed_references if allowed_references else [] + self._prefer_dict_key_access = prefer_dict_key_access + + def getattr(self, obj: Any, attribute: str) -> Any: + if self._prefer_dict_key_access and isinstance(obj, dict) and attribute in obj: + return obj[attribute] + return super().getattr(obj, attribute) + + def validate_template(self, user_template: str) -> None: + try: + ast = self.parse(user_template) + template_vars = meta.find_undeclared_variables(ast) + unallowed_vars = set(template_vars) - set(self.allowed_references) + if len(unallowed_vars) > 0: + raise UserTemplateError(f"Unknown variable references in Jinja template: {unallowed_vars}") + except Exception as exception: + maybe_handle_missing_filter_exception(exception, available_jinja_filters=list(self.filters.keys())) + raise exception + + def render_template( + self, + user_template: str, + record: dict, + skip_template_validation: bool = False, + ) -> str: + if not skip_template_validation: + self.validate_template(user_template) + + try: + template = self.from_string(user_template) + return template.render(record) + except SecurityError as exception: + raise UserTemplateError("Non-permitted operations in Jinja template.") from exception + except Exception as exception: + maybe_handle_missing_filter_exception(exception, available_jinja_filters=list(self.filters.keys())) + raise UserTemplateError(str(exception)) from exception + + def sanitize_user_exceptions(func): """Sanitize returned user-space exceptions.""" @@ -428,6 +492,27 @@ def my_func(self, user_template: str, records: list[dict]): _template_render_fn: Callable + def _get_jinja_rendering_engine(self) -> JinjaRenderingEngine: + if hasattr(self, "_jinja_rendering_engine"): + return JinjaRenderingEngine(getattr(self, "_jinja_rendering_engine")) + if hasattr(self, "_resource_provider"): + return JinjaRenderingEngine(self._resource_provider.run_config.jinja_rendering_engine) + return JinjaRenderingEngine.NATIVE + + def _create_render_environment( + self, + *, + dataset_variables: list[str], + record_str_fn: Callable[[Any], str] | None = None, + ) -> UserTemplateSandboxEnvironment | NativeJinjaSandboxEnvironment: + env_kwargs: dict[str, Any] = {} + if record_str_fn is not None: + env_kwargs["finalize"] = record_str_fn + env_kwargs["prefer_dict_key_access"] = True + if self._get_jinja_rendering_engine() == JinjaRenderingEngine.GINJA: + return UserTemplateSandboxEnvironment(allowed_references=dataset_variables, **env_kwargs) + return NativeJinjaSandboxEnvironment(allowed_references=dataset_variables, **env_kwargs) + @sanitize_user_exceptions def prepare_jinja2_template_renderer( self, @@ -445,14 +530,13 @@ def prepare_jinja2_template_renderer( and enables dict-key-priority attribute lookup for nested dot access ({{ col.sub.field }}). """ - env_kwargs: dict[str, Any] = {} - if record_str_fn is not None: - env_kwargs["finalize"] = record_str_fn - env_kwargs["prefer_dict_key_access"] = True - jinja_render_env = UserTemplateSandboxEnvironment(allowed_references=dataset_variables, **env_kwargs) + jinja_render_env = self._create_render_environment( + dataset_variables=dataset_variables, + record_str_fn=record_str_fn, + ) jinja_render_env.validate_template(prompt_template) self._template_render_fn = partial( - jinja_render_env.safe_render, + jinja_render_env.render_template, prompt_template, skip_template_validation=True, ) @@ -470,10 +554,10 @@ def prepare_jinja2_multi_template_renderer( ) -> None: if not self._template_prepared_in_multi_template_renderer(template_name): self._create_render_func_registry() - jinja_render_env = UserTemplateSandboxEnvironment(allowed_references=dataset_variables) + jinja_render_env = self._create_render_environment(dataset_variables=dataset_variables) jinja_render_env.validate_template(prompt_template) self._render_func_registry[template_name] = partial( - jinja_render_env.safe_render, + jinja_render_env.render_template, prompt_template, skip_template_validation=True, ) diff --git a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/generator.py b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/generator.py index 18bdeffd3..7875cc5c1 100644 --- a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/generator.py +++ b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/generator.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING import data_designer.lazy_heavy_imports as lazy +from data_designer.config.run_config import JinjaRenderingEngine from data_designer.engine.sampling_gen.data_sources.base import RadomStateT from data_designer.engine.sampling_gen.errors import RejectionSamplingError from data_designer.engine.sampling_gen.jinja_utils import JinjaDataFrame @@ -49,6 +50,7 @@ def __init__( *, schema: DataSchema | None = None, max_rejections_factor: int = 5, + jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.NATIVE, ): # This is temporary while we need the legacy and refactored code to coexist. if schema is not None: @@ -63,6 +65,7 @@ def __init__( self.rng = check_random_state(random_state) self._dag = self.schema.dag.to_networkx() + self._jinja_rendering_engine = jinja_rendering_engine self._shared_sampler_kwargs = { "random_state": self.rng, "people_gen_resource": create_people_gen_resource(self.schema, person_generator_loader), @@ -81,7 +84,10 @@ def _run_rejection_sampling(self, df: pd.DataFrame, column: ConditionalDataColum while needs_samples.any(): for condition in column.conditions: - index = JinjaDataFrame(condition).select_index(df[needs_samples]) + index = JinjaDataFrame( + condition, + jinja_rendering_engine=self._jinja_rendering_engine, + ).select_index(df[needs_samples]) src = column.get_sampler(condition, **self._shared_sampler_kwargs) df = src.inject_data_column(df, name, index) diff --git a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py index c10726116..a60e8368e 100644 --- a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py +++ b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py @@ -6,9 +6,12 @@ import ast from typing import TYPE_CHECKING, Any +from jinja2 import meta +from jinja2.sandbox import ImmutableSandboxedEnvironment + import data_designer.lazy_heavy_imports as lazy +from data_designer.config.run_config import JinjaRenderingEngine from data_designer.engine.processing.ginja.environment import ( - UserTemplateSandboxEnvironment, WithJinja2UserTemplateRendering, ) @@ -17,8 +20,14 @@ class JinjaDataFrame(WithJinja2UserTemplateRendering): - def __init__(self, expr: str): + def __init__( + self, + expr: str, + *, + jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.NATIVE, + ): self.expr = expr + self._jinja_rendering_engine = jinja_rendering_engine def _jsonify(self, record) -> dict[str, Any]: for key, value in record.items(): @@ -61,4 +70,4 @@ def to_column(self, dataframe: pd.DataFrame) -> list[Any]: def extract_column_names_from_expression(expr: str) -> set[str]: """Extract valid column names from the given expression.""" - return UserTemplateSandboxEnvironment().get_references("{{ " + expr + " }}") + return meta.find_undeclared_variables(ImmutableSandboxedEnvironment().parse("{{ " + expr + " }}")) diff --git a/packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py b/packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py index 63a8dc6b2..68bfc008e 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py +++ b/packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py @@ -9,8 +9,10 @@ import data_designer.lazy_heavy_imports as lazy from data_designer.config.column_configs import ExpressionColumnConfig +from data_designer.config.run_config import JinjaRenderingEngine, RunConfig from data_designer.engine.column_generators.generators.expression import ExpressionColumnGenerator from data_designer.engine.column_generators.utils.errors import ExpressionTemplateRenderError +from data_designer.engine.processing.ginja.exceptions import UserTemplateUnsupportedFiltersError from data_designer.engine.resources.resource_provider import ResourceProvider @@ -160,3 +162,26 @@ def test_generate_with_missing_columns(): match=r"There was an error preparing the Jinja2 expression template. The following columns \['col1'\] are missing!", ): generator.generate(df) + + +def test_generate_respects_run_config_jinja_rendering_engine() -> None: + df = lazy.pd.DataFrame({"col1": [["a", "b"]]}) + + native_provider = Mock(spec=ResourceProvider) + native_provider.run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.NATIVE) + native_generator = _create_test_generator( + _create_test_config("joined", "{{ col1 | join('-') }}", "str"), + native_provider, + ) + native_result = native_generator.generate(df) + assert native_result["joined"].tolist() == ["a-b"] + + ginja_provider = Mock(spec=ResourceProvider) + ginja_provider.run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.GINJA) + ginja_generator = _create_test_generator( + _create_test_config("joined", "{{ col1 | join('-') }}", "str"), + ginja_provider, + ) + + with pytest.raises(UserTemplateUnsupportedFiltersError): + ginja_generator.generate(df) diff --git a/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py b/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py index 81f63f849..2ee26e96c 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py +++ b/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py @@ -12,7 +12,9 @@ LLMTextColumnConfig, Score, ) +from data_designer.config.run_config import JinjaRenderingEngine from data_designer.config.utils.code_lang import CodeLang +from data_designer.engine.column_generators.utils.errors import PromptTemplateRenderError from data_designer.engine.column_generators.utils.prompt_renderer import ( PromptType, RecordBasedPromptRenderer, @@ -125,3 +127,33 @@ def test_prompt_renderer_render_prompt_template_error(): renderer.render( prompt_template="Test prompt: {{ invalid_template }}", record=data, prompt_type=PromptType.USER_PROMPT ) + + +def test_prompt_renderer_uses_native_jinja_by_default() -> None: + config = LLMTextColumnConfig(name="test_column", prompt="Test prompt", model_alias="test_model") + recipe = create_response_recipe(config) + renderer = RecordBasedPromptRenderer(response_recipe=recipe) + + result = renderer.render( + prompt_template="Joined: {{ input | join('-') }}", + record={"input": ["Hello", "World"]}, + prompt_type=PromptType.USER_PROMPT, + ) + + assert result == "Joined: Hello-World" + + +def test_prompt_renderer_can_opt_into_ginja() -> None: + config = LLMTextColumnConfig(name="test_column", prompt="Test prompt", model_alias="test_model") + recipe = create_response_recipe(config) + renderer = RecordBasedPromptRenderer( + response_recipe=recipe, + jinja_rendering_engine=JinjaRenderingEngine.GINJA, + ) + + with pytest.raises(PromptTemplateRenderError, match=r"\| join"): + renderer.render( + prompt_template="Joined: {{ input | join('-') }}", + record={"input": ["Hello", "World"]}, + prompt_type=PromptType.USER_PROMPT, + ) diff --git a/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py b/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py index e2a21c35a..2b539637b 100644 --- a/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py +++ b/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py @@ -8,6 +8,8 @@ import pytest import data_designer.lazy_heavy_imports as lazy +from data_designer.config.run_config import JinjaRenderingEngine +from data_designer.engine.processing.ginja.exceptions import UserTemplateUnsupportedFiltersError from data_designer.engine.sampling_gen.jinja_utils import JinjaDataFrame, extract_column_names_from_expression @@ -113,3 +115,19 @@ def test_jinja_dataframe_to_column_scenarios(test_case, expr, df_data, mock_side jdf.render_template = Mock(side_effect=mock_side_effect) result = jdf.to_column(df) assert result == expected_result + + +def test_jinja_dataframe_can_switch_rendering_engines() -> None: + df = lazy.pd.DataFrame({"items": [["a", "b"]]}) + + native_result = JinjaDataFrame( + "items | join('-')", + jinja_rendering_engine=JinjaRenderingEngine.NATIVE, + ).to_column(df) + assert native_result == ["a-b"] + + with pytest.raises(UserTemplateUnsupportedFiltersError): + JinjaDataFrame( + "items | join('-')", + jinja_rendering_engine=JinjaRenderingEngine.GINJA, + ).to_column(df) From 1a2b6d25ac1f6826528ada41af2aa8ea5e0760a3 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 10:34:28 -0400 Subject: [PATCH 02/13] test: align engine tests with native jinja default - opt the ginja mixin regression tests into the hardened renderer explicitly now that RunConfig defaults engine rendering to native - update the image empty-prompt assertion to expect the native-mode ValueError surfaced by the generator Signed-off-by: Eric W. Tramel --- .../tests/engine/column_generators/generators/test_image.py | 5 ++--- .../tests/engine/processing/ginja/test_environment.py | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py b/packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py index fe42bb438..43eafc788 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py +++ b/packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py @@ -10,7 +10,6 @@ from data_designer.config.models import ImageContext, ImageFormat, ModalityDataType from data_designer.engine.column_generators.generators.base import GenerationStrategy from data_designer.engine.column_generators.generators.image import ImageCellGenerator -from data_designer.engine.processing.ginja.exceptions import UserTemplateError @pytest.fixture @@ -114,13 +113,13 @@ def test_image_cell_generator_missing_columns_error(stub_image_column_config, st def test_image_cell_generator_empty_prompt_error(stub_resource_provider): - """Test that empty rendered prompt raises UserTemplateError.""" + """Test that empty rendered prompt raises ValueError in native mode.""" # Create config with template that renders to empty string config = ImageColumnConfig(name="test_image", prompt="{{ empty }}", model_alias="test_model") generator = ImageCellGenerator(config=config, resource_provider=stub_resource_provider) - with pytest.raises(UserTemplateError): + with pytest.raises(ValueError, match="empty"): generator.generate(data={"empty": ""}) diff --git a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py index 69eb98206..c661f76f3 100644 --- a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py +++ b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py @@ -3,6 +3,7 @@ import pytest +from data_designer.config.run_config import JinjaRenderingEngine from data_designer.engine.processing.ginja.environment import ( ALLOWED_JINJA_FILTERS, UserTemplateSandboxEnvironment, @@ -180,6 +181,7 @@ def test_with_jinja2_user_template_rendering_mixin(test_case, template_1, templa class Foo(WithJinja2UserTemplateRendering): def __init__(self, template_1: str, template_2: str = None): + self._jinja_rendering_engine = JinjaRenderingEngine.GINJA if template_2 is None: # Single template self.prepare_jinja2_template_renderer(template_1, dataset_variables=["safe"]) From d3741c0fc38061ec043ebcf3d9fc6bedb9b13a05 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 11:21:24 -0400 Subject: [PATCH 03/13] refactor: rename public secure jinja mode - rename the public RunConfig enum option from ginja to secure so the interface reads as native versus secure - update docs and tests to use the new public enum member and keep the hardened renderer wired through the same engine seam Signed-off-by: Eric W. Tramel --- docs/code_reference/run_config.md | 2 +- .../src/data_designer/config/run_config.py | 6 +++--- .../tests/config/test_run_config.py | 6 +++--- .../engine/processing/ginja/environment.py | 2 +- .../column_generators/generators/test_expression.py | 10 +++++----- .../column_generators/utils/test_prompt_renderer.py | 4 ++-- .../tests/engine/processing/ginja/test_environment.py | 2 +- .../tests/engine/sampling_gen/test_jinja_utils.py | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/code_reference/run_config.md b/docs/code_reference/run_config.md index f3ce4fb94..984032eff 100644 --- a/docs/code_reference/run_config.md +++ b/docs/code_reference/run_config.md @@ -14,7 +14,7 @@ data_designer = DataDesigner() data_designer.set_run_config(dd.RunConfig( buffer_size=500, max_conversation_restarts=3, - jinja_rendering_engine=dd.JinjaRenderingEngine.GINJA, + jinja_rendering_engine=dd.JinjaRenderingEngine.SECURE, )) ``` diff --git a/packages/data-designer-config/src/data_designer/config/run_config.py b/packages/data-designer-config/src/data_designer/config/run_config.py index 6df8d30b5..ab1aa16ee 100644 --- a/packages/data-designer-config/src/data_designer/config/run_config.py +++ b/packages/data-designer-config/src/data_designer/config/run_config.py @@ -16,7 +16,7 @@ class JinjaRenderingEngine(StrEnum): """Template renderer used by the engine for user-supplied Jinja templates.""" NATIVE = "native" - GINJA = "ginja" + SECURE = "secure" class ThrottleConfig(ConfigBase): @@ -109,7 +109,7 @@ class RunConfig(ConfigBase): consolidated log block. Must be > 0. Default is 5.0. jinja_rendering_engine: Template renderer used for engine-side Jinja evaluation. ``native`` uses Jinja2's built-in sandbox with the standard filter set and - fewer Data Designer-specific restrictions. ``ginja`` uses Data Designer's + fewer Data Designer-specific restrictions. ``secure`` uses Data Designer's hardened sandbox with additional AST, filter, and output guards. Default is ``native``. throttle: AIMD throttle tuning parameters. See ``ThrottleConfig`` for details. @@ -129,7 +129,7 @@ class RunConfig(ConfigBase): default=JinjaRenderingEngine.NATIVE, description=( "Template renderer used for engine-side Jinja evaluation. " - "`native` uses Jinja2's built-in sandbox; `ginja` uses Data Designer's hardened sandbox." + "`native` uses Jinja2's built-in sandbox; `secure` uses Data Designer's hardened sandbox." ), ) throttle: ThrottleConfig = Field(default_factory=ThrottleConfig) diff --git a/packages/data-designer-config/tests/config/test_run_config.py b/packages/data-designer-config/tests/config/test_run_config.py index 3d39cf5a0..cbb89782f 100644 --- a/packages/data-designer-config/tests/config/test_run_config.py +++ b/packages/data-designer-config/tests/config/test_run_config.py @@ -10,6 +10,6 @@ def test_run_config_defaults_to_native_jinja_renderer() -> None: assert JinjaRenderingEngine(RunConfig().jinja_rendering_engine) == JinjaRenderingEngine.NATIVE -def test_run_config_accepts_ginja_renderer() -> None: - run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.GINJA) - assert JinjaRenderingEngine(run_config.jinja_rendering_engine) == JinjaRenderingEngine.GINJA +def test_run_config_accepts_secure_renderer() -> None: + run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.SECURE) + assert JinjaRenderingEngine(run_config.jinja_rendering_engine) == JinjaRenderingEngine.SECURE diff --git a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py index 10f8d32d9..16b4bd250 100644 --- a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py +++ b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py @@ -509,7 +509,7 @@ def _create_render_environment( if record_str_fn is not None: env_kwargs["finalize"] = record_str_fn env_kwargs["prefer_dict_key_access"] = True - if self._get_jinja_rendering_engine() == JinjaRenderingEngine.GINJA: + if self._get_jinja_rendering_engine() == JinjaRenderingEngine.SECURE: return UserTemplateSandboxEnvironment(allowed_references=dataset_variables, **env_kwargs) return NativeJinjaSandboxEnvironment(allowed_references=dataset_variables, **env_kwargs) diff --git a/packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py b/packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py index 68bfc008e..15080368f 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py +++ b/packages/data-designer-engine/tests/engine/column_generators/generators/test_expression.py @@ -176,12 +176,12 @@ def test_generate_respects_run_config_jinja_rendering_engine() -> None: native_result = native_generator.generate(df) assert native_result["joined"].tolist() == ["a-b"] - ginja_provider = Mock(spec=ResourceProvider) - ginja_provider.run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.GINJA) - ginja_generator = _create_test_generator( + secure_provider = Mock(spec=ResourceProvider) + secure_provider.run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.SECURE) + secure_generator = _create_test_generator( _create_test_config("joined", "{{ col1 | join('-') }}", "str"), - ginja_provider, + secure_provider, ) with pytest.raises(UserTemplateUnsupportedFiltersError): - ginja_generator.generate(df) + secure_generator.generate(df) diff --git a/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py b/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py index 2ee26e96c..7064d4c94 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py +++ b/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py @@ -143,12 +143,12 @@ def test_prompt_renderer_uses_native_jinja_by_default() -> None: assert result == "Joined: Hello-World" -def test_prompt_renderer_can_opt_into_ginja() -> None: +def test_prompt_renderer_can_opt_into_secure_mode() -> None: config = LLMTextColumnConfig(name="test_column", prompt="Test prompt", model_alias="test_model") recipe = create_response_recipe(config) renderer = RecordBasedPromptRenderer( response_recipe=recipe, - jinja_rendering_engine=JinjaRenderingEngine.GINJA, + jinja_rendering_engine=JinjaRenderingEngine.SECURE, ) with pytest.raises(PromptTemplateRenderError, match=r"\| join"): diff --git a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py index c661f76f3..2940b5dda 100644 --- a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py +++ b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py @@ -181,7 +181,7 @@ def test_with_jinja2_user_template_rendering_mixin(test_case, template_1, templa class Foo(WithJinja2UserTemplateRendering): def __init__(self, template_1: str, template_2: str = None): - self._jinja_rendering_engine = JinjaRenderingEngine.GINJA + self._jinja_rendering_engine = JinjaRenderingEngine.SECURE if template_2 is None: # Single template self.prepare_jinja2_template_renderer(template_1, dataset_variables=["safe"]) diff --git a/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py b/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py index 2b539637b..24f11037c 100644 --- a/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py +++ b/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py @@ -129,5 +129,5 @@ def test_jinja_dataframe_can_switch_rendering_engines() -> None: with pytest.raises(UserTemplateUnsupportedFiltersError): JinjaDataFrame( "items | join('-')", - jinja_rendering_engine=JinjaRenderingEngine.GINJA, + jinja_rendering_engine=JinjaRenderingEngine.SECURE, ).to_column(df) From 7ac7436b9f1759f346a52d41583a5aeec6de8d33 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 11:47:24 -0400 Subject: [PATCH 04/13] fix: default jinja rendering to secure Signed-off-by: Eric W. Tramel --- docs/code_reference/run_config.md | 6 +++- .../src/data_designer/config/run_config.py | 4 +-- .../tests/config/test_run_config.py | 10 +++--- .../utils/prompt_renderer.py | 2 +- .../engine/processing/ginja/environment.py | 2 +- .../engine/sampling_gen/generator.py | 2 +- .../engine/sampling_gen/jinja_utils.py | 2 +- .../generators/test_image.py | 5 +-- .../utils/test_prompt_renderer.py | 32 +++++++++---------- .../processing/ginja/test_environment.py | 10 ++++++ .../engine/sampling_gen/test_jinja_utils.py | 15 ++++++--- 11 files changed, 56 insertions(+), 34 deletions(-) diff --git a/docs/code_reference/run_config.md b/docs/code_reference/run_config.md index 984032eff..44d8ce73a 100644 --- a/docs/code_reference/run_config.md +++ b/docs/code_reference/run_config.md @@ -4,6 +4,10 @@ The `run_config` module defines runtime settings that control dataset generation including early shutdown thresholds, batch sizing, non-inference worker concurrency, and the Jinja rendering engine used by the runtime. +`JinjaRenderingEngine.SECURE` is the default. Set `JinjaRenderingEngine.NATIVE` +when you want Jinja2's broader built-in sandbox behavior instead of Data Designer's +hardened renderer. + ## Usage ```python @@ -14,7 +18,7 @@ data_designer = DataDesigner() data_designer.set_run_config(dd.RunConfig( buffer_size=500, max_conversation_restarts=3, - jinja_rendering_engine=dd.JinjaRenderingEngine.SECURE, + jinja_rendering_engine=dd.JinjaRenderingEngine.NATIVE, )) ``` diff --git a/packages/data-designer-config/src/data_designer/config/run_config.py b/packages/data-designer-config/src/data_designer/config/run_config.py index ab1aa16ee..d5f10c9e5 100644 --- a/packages/data-designer-config/src/data_designer/config/run_config.py +++ b/packages/data-designer-config/src/data_designer/config/run_config.py @@ -111,7 +111,7 @@ class RunConfig(ConfigBase): ``native`` uses Jinja2's built-in sandbox with the standard filter set and fewer Data Designer-specific restrictions. ``secure`` uses Data Designer's hardened sandbox with additional AST, filter, and output guards. - Default is ``native``. + Default is ``secure``. throttle: AIMD throttle tuning parameters. See ``ThrottleConfig`` for details. """ @@ -126,7 +126,7 @@ class RunConfig(ConfigBase): progress_bar: bool = False progress_interval: float = Field(default=5.0, gt=0.0) jinja_rendering_engine: JinjaRenderingEngine = Field( - default=JinjaRenderingEngine.NATIVE, + default=JinjaRenderingEngine.SECURE, description=( "Template renderer used for engine-side Jinja evaluation. " "`native` uses Jinja2's built-in sandbox; `secure` uses Data Designer's hardened sandbox." diff --git a/packages/data-designer-config/tests/config/test_run_config.py b/packages/data-designer-config/tests/config/test_run_config.py index cbb89782f..98c819b38 100644 --- a/packages/data-designer-config/tests/config/test_run_config.py +++ b/packages/data-designer-config/tests/config/test_run_config.py @@ -6,10 +6,10 @@ from data_designer.config.run_config import JinjaRenderingEngine, RunConfig -def test_run_config_defaults_to_native_jinja_renderer() -> None: - assert JinjaRenderingEngine(RunConfig().jinja_rendering_engine) == JinjaRenderingEngine.NATIVE +def test_run_config_defaults_to_secure_jinja_renderer() -> None: + assert JinjaRenderingEngine(RunConfig().jinja_rendering_engine) == JinjaRenderingEngine.SECURE -def test_run_config_accepts_secure_renderer() -> None: - run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.SECURE) - assert JinjaRenderingEngine(run_config.jinja_rendering_engine) == JinjaRenderingEngine.SECURE +def test_run_config_accepts_native_renderer() -> None: + run_config = RunConfig(jinja_rendering_engine=JinjaRenderingEngine.NATIVE) + assert JinjaRenderingEngine(run_config.jinja_rendering_engine) == JinjaRenderingEngine.NATIVE diff --git a/packages/data-designer-engine/src/data_designer/engine/column_generators/utils/prompt_renderer.py b/packages/data-designer-engine/src/data_designer/engine/column_generators/utils/prompt_renderer.py index e0b085c67..8a3338d44 100644 --- a/packages/data-designer-engine/src/data_designer/engine/column_generators/utils/prompt_renderer.py +++ b/packages/data-designer-engine/src/data_designer/engine/column_generators/utils/prompt_renderer.py @@ -42,7 +42,7 @@ def __init__( response_recipe: ResponseRecipe, *, error_message_context: dict[str, str] | None = None, - jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.NATIVE, + jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.SECURE, ): self.response_recipe = response_recipe self._error_message_context = error_message_context diff --git a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py index 16b4bd250..791b96141 100644 --- a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py +++ b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py @@ -497,7 +497,7 @@ def _get_jinja_rendering_engine(self) -> JinjaRenderingEngine: return JinjaRenderingEngine(getattr(self, "_jinja_rendering_engine")) if hasattr(self, "_resource_provider"): return JinjaRenderingEngine(self._resource_provider.run_config.jinja_rendering_engine) - return JinjaRenderingEngine.NATIVE + return JinjaRenderingEngine.SECURE def _create_render_environment( self, diff --git a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/generator.py b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/generator.py index 7875cc5c1..b316b5ba2 100644 --- a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/generator.py +++ b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/generator.py @@ -50,7 +50,7 @@ def __init__( *, schema: DataSchema | None = None, max_rejections_factor: int = 5, - jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.NATIVE, + jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.SECURE, ): # This is temporary while we need the legacy and refactored code to coexist. if schema is not None: diff --git a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py index a60e8368e..9aa0210ab 100644 --- a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py +++ b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py @@ -24,7 +24,7 @@ def __init__( self, expr: str, *, - jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.NATIVE, + jinja_rendering_engine: JinjaRenderingEngine = JinjaRenderingEngine.SECURE, ): self.expr = expr self._jinja_rendering_engine = jinja_rendering_engine diff --git a/packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py b/packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py index 43eafc788..e60212f1b 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py +++ b/packages/data-designer-engine/tests/engine/column_generators/generators/test_image.py @@ -10,6 +10,7 @@ from data_designer.config.models import ImageContext, ImageFormat, ModalityDataType from data_designer.engine.column_generators.generators.base import GenerationStrategy from data_designer.engine.column_generators.generators.image import ImageCellGenerator +from data_designer.engine.processing.ginja.exceptions import UserTemplateError @pytest.fixture @@ -113,13 +114,13 @@ def test_image_cell_generator_missing_columns_error(stub_image_column_config, st def test_image_cell_generator_empty_prompt_error(stub_resource_provider): - """Test that empty rendered prompt raises ValueError in native mode.""" + """Test that empty rendered prompt is rejected by the secure renderer.""" # Create config with template that renders to empty string config = ImageColumnConfig(name="test_image", prompt="{{ empty }}", model_alias="test_model") generator = ImageCellGenerator(config=config, resource_provider=stub_resource_provider) - with pytest.raises(ValueError, match="empty"): + with pytest.raises(UserTemplateError, match="invalid"): generator.generate(data={"empty": ""}) diff --git a/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py b/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py index 7064d4c94..bdc7e6c8b 100644 --- a/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py +++ b/packages/data-designer-engine/tests/engine/column_generators/utils/test_prompt_renderer.py @@ -129,31 +129,31 @@ def test_prompt_renderer_render_prompt_template_error(): ) -def test_prompt_renderer_uses_native_jinja_by_default() -> None: +def test_prompt_renderer_uses_secure_jinja_by_default() -> None: config = LLMTextColumnConfig(name="test_column", prompt="Test prompt", model_alias="test_model") recipe = create_response_recipe(config) renderer = RecordBasedPromptRenderer(response_recipe=recipe) - result = renderer.render( - prompt_template="Joined: {{ input | join('-') }}", - record={"input": ["Hello", "World"]}, - prompt_type=PromptType.USER_PROMPT, - ) - - assert result == "Joined: Hello-World" + with pytest.raises(PromptTemplateRenderError, match=r"\| join"): + renderer.render( + prompt_template="Joined: {{ input | join('-') }}", + record={"input": ["Hello", "World"]}, + prompt_type=PromptType.USER_PROMPT, + ) -def test_prompt_renderer_can_opt_into_secure_mode() -> None: +def test_prompt_renderer_can_opt_into_native_mode() -> None: config = LLMTextColumnConfig(name="test_column", prompt="Test prompt", model_alias="test_model") recipe = create_response_recipe(config) renderer = RecordBasedPromptRenderer( response_recipe=recipe, - jinja_rendering_engine=JinjaRenderingEngine.SECURE, + jinja_rendering_engine=JinjaRenderingEngine.NATIVE, ) - with pytest.raises(PromptTemplateRenderError, match=r"\| join"): - renderer.render( - prompt_template="Joined: {{ input | join('-') }}", - record={"input": ["Hello", "World"]}, - prompt_type=PromptType.USER_PROMPT, - ) + result = renderer.render( + prompt_template="Joined: {{ input | join('-') }}", + record={"input": ["Hello", "World"]}, + prompt_type=PromptType.USER_PROMPT, + ) + + assert result == "Joined: Hello-World" diff --git a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py index 2940b5dda..1c07ae780 100644 --- a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py +++ b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py @@ -213,3 +213,13 @@ def bar(self, record): else: with pytest.raises(expected_result): f = Foo(template_1, template_2) + + +def test_with_jinja2_user_template_rendering_defaults_to_secure_mode() -> None: + class Foo(WithJinja2UserTemplateRendering): + pass + + renderer = Foo() + + with pytest.raises(UserTemplateUnsupportedFiltersError): + renderer.prepare_jinja2_template_renderer("{{ items | join('-') }}", dataset_variables=["items"]) diff --git a/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py b/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py index 24f11037c..efea798e3 100644 --- a/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py +++ b/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py @@ -120,14 +120,21 @@ def test_jinja_dataframe_to_column_scenarios(test_case, expr, df_data, mock_side def test_jinja_dataframe_can_switch_rendering_engines() -> None: df = lazy.pd.DataFrame({"items": [["a", "b"]]}) + with pytest.raises(UserTemplateUnsupportedFiltersError): + JinjaDataFrame( + "items | join('-')", + jinja_rendering_engine=JinjaRenderingEngine.SECURE, + ).to_column(df) + native_result = JinjaDataFrame( "items | join('-')", jinja_rendering_engine=JinjaRenderingEngine.NATIVE, ).to_column(df) assert native_result == ["a-b"] + +def test_jinja_dataframe_uses_secure_jinja_by_default() -> None: + df = lazy.pd.DataFrame({"items": [["a", "b"]]}) + with pytest.raises(UserTemplateUnsupportedFiltersError): - JinjaDataFrame( - "items | join('-')", - jinja_rendering_engine=JinjaRenderingEngine.SECURE, - ).to_column(df) + JinjaDataFrame("items | join('-')").to_column(df) From 64ed498cd2f25191ab41d24322e1c354b2d92649 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 11:52:49 -0400 Subject: [PATCH 05/13] docs: add security concept guide Signed-off-by: Eric W. Tramel --- docs/code_reference/run_config.md | 2 + docs/concepts/deployment-options.md | 3 + docs/concepts/security.md | 86 +++++++++++++++++++++++++++++ mkdocs.yml | 1 + 4 files changed, 92 insertions(+) create mode 100644 docs/concepts/security.md diff --git a/docs/code_reference/run_config.md b/docs/code_reference/run_config.md index 44d8ce73a..ae358d5e0 100644 --- a/docs/code_reference/run_config.md +++ b/docs/code_reference/run_config.md @@ -8,6 +8,8 @@ and the Jinja rendering engine used by the runtime. when you want Jinja2's broader built-in sandbox behavior instead of Data Designer's hardened renderer. +For guidance on when to use each mode, see [Security](../concepts/security.md). + ## Usage ```python diff --git a/docs/concepts/deployment-options.md b/docs/concepts/deployment-options.md index a77af1070..ca7278ffa 100644 --- a/docs/concepts/deployment-options.md +++ b/docs/concepts/deployment-options.md @@ -141,6 +141,8 @@ If you need to provide synthetic data generation as a shared service: - **Job management**: Queue, monitor, and manage generation jobs centrally - **Resource sharing**: Shared infrastructure for SDG workloads +When users can submit configs containing Jinja templates to a shared engine, template rendering becomes a remote code execution concern and part of your security boundary. See [Security](security.md) for guidance on when to keep the default `JinjaRenderingEngine.SECURE` mode. + --- ## 🧭 Decision Flowchart @@ -181,3 +183,4 @@ If you need to provide synthetic data generation as a shared service: - **Library**: Continue with this documentation - **Microservice**: See the [NeMo Data Designer Microservice documentation](https://docs.nvidia.com/nemo/microservices/latest/design-synthetic-data-from-scratch-or-seeds/index.html){target="_blank"} +- **Security model**: See [Security](security.md) diff --git a/docs/concepts/security.md b/docs/concepts/security.md new file mode 100644 index 000000000..8714244b7 --- /dev/null +++ b/docs/concepts/security.md @@ -0,0 +1,86 @@ +# Security + +Data Designer can run in two very different trust models: + +- **Trusted / monolithic**: The same user or team writes the config and runs the engine. +- **Untrusted / shared execution**: One user submits a config and a different process, service, or team executes it. + +That distinction matters for Jinja template rendering. In a trusted local workflow, broader template flexibility may be acceptable. In a shared-service deployment, user-supplied Jinja becomes part of the engine's remote code execution surface. A template sandbox escape would execute inside the process running Data Designer. + +See [Deployment Options](deployment-options.md) for the architectures where that trust boundary changes. + +!!! warning "Treat untrusted Jinja as a security boundary" + If many users can submit configs to one engine, or if configs are accepted over an API and executed elsewhere, keep `JinjaRenderingEngine.SECURE`. In that model, Jinja templates are no longer just prompt-formatting helpers. They are untrusted user programs being evaluated by your engine. + +## Jinja Rendering Modes + +Data Designer exposes the renderer choice through `RunConfig`: + +```python +import data_designer.config as dd + +run_config = dd.RunConfig( + jinja_rendering_engine=dd.JinjaRenderingEngine.SECURE, +) +``` + +`SECURE` is the default. Opt into `NATIVE` only when you are comfortable treating the config author and the engine operator as the same trust domain. + +| Mode | What it uses | Best fit | +|------|---------------|----------| +| `SECURE` | Data Designer's hardened renderer built on top of Jinja2's sandbox | Shared services, microservices, internal platforms, or any deployment where config submission is separated from execution | +| `NATIVE` | Jinja2's built-in sandbox with Data Designer's variable whitelist | Local library usage and other trusted, monolithic workflows that want broader Jinja behavior | + +## What Both Modes Already Do + +`NATIVE` is not an unrestricted Python template engine. Both modes still provide some baseline containment: + +- Both use Jinja2's `ImmutableSandboxedEnvironment`. +- Both only allow references to explicitly provided dataset variables. +- Both still reject sandboxed operations that Jinja2 itself disallows. + +The difference is that `SECURE` adds another layer of Data Designer-specific restrictions on top of that baseline. + +## What `SECURE` Adds on Top of Standard Jinja Sandbox + +The `SECURE` renderer uses a hardened environment implemented in `packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py`. Compared with the standard Jinja sandbox, it adds several additional controls: + +- **Record sanitization before render**: Template context is serialized and deserialized into basic JSON-compatible types before rendering. This reduces the chance that unexpected Python objects expose attributes or callables to the template. +- **Filter allowlist**: Only a limited set of filters is available. This includes a small set of built-in Jinja filters plus Data Designer's custom `jsonpath` filter. +- **Unsupported template features removed**: `SECURE` rejects `import`, `macro`, `set`, `extends`, and `block`. +- **Loop restrictions**: Recursive loops and nested `for` loops are rejected. +- **AST complexity limits**: Templates are statically analyzed and rejected if they exceed the current complexity thresholds of 600 AST nodes or depth 10. +- **`self` references blocked**: Templates cannot reference `self`, which reduces access to template internals. +- **Rendered output guards**: Empty output is rejected, very large output is rejected, and rendered strings that look like Python built-in or function representations are rejected. +- **Sanitized user-facing errors**: At the engine boundary, most template errors are normalized to a generic invalid-template message instead of surfacing internal exception details. + +These controls exist because the standard sandbox is a good baseline, but shared-service deployments need a narrower and more defensive execution model. + +## Why This Matters in Multi-User Deployments + +The security posture changes as soon as config submission and execution are separated. + +Examples: + +- A centralized Data Designer service accepts configs from many users. +- An internal platform lets users upload or edit configs that are executed by a background worker. +- A REST API accepts Jinja-containing configs and runs them on server-side infrastructure. + +In those environments, templates are no longer just local convenience syntax. They are untrusted input being evaluated by infrastructure the submitter does not control. In practice, that makes Jinja rendering a remote code execution concern, which is why `SECURE` exists and why it remains the default. + +If you are deciding between local library usage and a shared service model, read [Deployment Options](deployment-options.md). The library patterns are often still "trusted" deployments. The shared microservice pattern is not. + +## When To Use `NATIVE` + +Use `NATIVE` when all of the following are true: + +- The person submitting the config is also the person running the engine, or they are in the same trusted operational boundary. +- You want broader standard Jinja behavior than `SECURE` allows. +- You understand that this is a flexibility tradeoff, not the safer default. + +For example, this is often reasonable in a notebook, local script, or other single-user library workflow. + +## Related Reading + +- [Deployment Options](deployment-options.md) +- [Run Config Reference](../code_reference/run_config.md) diff --git a/mkdocs.yml b/mkdocs.yml index cb20ed4e7..464491d4f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Safety & Limits: concepts/mcp/safety-and-limits.md - Architecture & Performance: concepts/architecture-and-performance.md - Deployment Options: concepts/deployment-options.md + - Security: concepts/security.md - Tutorials: - Overview: notebooks/README.md - The Basics: notebooks/1-the-basics.ipynb From 7dae3dd454afcf109369527ce78a89a32cb954e0 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 12:49:55 -0400 Subject: [PATCH 06/13] fix: expose jsonpath in native jinja Signed-off-by: Eric W. Tramel --- docs/concepts/security.md | 28 +++++++++++-------- .../engine/processing/ginja/environment.py | 1 + .../processing/ginja/test_environment.py | 7 +++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/docs/concepts/security.md b/docs/concepts/security.md index 8714244b7..0ec671c92 100644 --- a/docs/concepts/security.md +++ b/docs/concepts/security.md @@ -5,13 +5,10 @@ Data Designer can run in two very different trust models: - **Trusted / monolithic**: The same user or team writes the config and runs the engine. - **Untrusted / shared execution**: One user submits a config and a different process, service, or team executes it. -That distinction matters for Jinja template rendering. In a trusted local workflow, broader template flexibility may be acceptable. In a shared-service deployment, user-supplied Jinja becomes part of the engine's remote code execution surface. A template sandbox escape would execute inside the process running Data Designer. +That distinction matters for features that evaluate user-supplied configuration at runtime, such as Jinja template rendering. In a trusted local workflow, broader template flexibility may be acceptable. In a shared-service deployment, user-supplied Jinja becomes part of the engine's remote code execution surface. A template sandbox escape would execute inside the process running Data Designer. See [Deployment Options](deployment-options.md) for the architectures where that trust boundary changes. -!!! warning "Treat untrusted Jinja as a security boundary" - If many users can submit configs to one engine, or if configs are accepted over an API and executed elsewhere, keep `JinjaRenderingEngine.SECURE`. In that model, Jinja templates are no longer just prompt-formatting helpers. They are untrusted user programs being evaluated by your engine. - ## Jinja Rendering Modes Data Designer exposes the renderer choice through `RunConfig`: @@ -31,22 +28,31 @@ run_config = dd.RunConfig( | `SECURE` | Data Designer's hardened renderer built on top of Jinja2's sandbox | Shared services, microservices, internal platforms, or any deployment where config submission is separated from execution | | `NATIVE` | Jinja2's built-in sandbox with Data Designer's variable whitelist | Local library usage and other trusted, monolithic workflows that want broader Jinja behavior | -## What Both Modes Already Do +!!! warning "Treat untrusted Jinja as a security boundary" + If many users can submit configs to one engine, or if configs are accepted over an API and executed elsewhere, keep `JinjaRenderingEngine.SECURE`. In that model, Jinja templates are no longer just prompt-formatting helpers. They are untrusted user programs being evaluated by your engine. -`NATIVE` is not an unrestricted Python template engine. Both modes still provide some baseline containment: +## Compatibility Matrix -- Both use Jinja2's `ImmutableSandboxedEnvironment`. -- Both only allow references to explicitly provided dataset variables. -- Both still reject sandboxed operations that Jinja2 itself disallows. +`NATIVE` is not an unrestricted Python template engine. The matrix below shows what each mode permits, restricts, or adds on top of Jinja2's standard sandbox behavior. -The difference is that `SECURE` adds another layer of Data Designer-specific restrictions on top of that baseline. +| Capability | `NATIVE` | `SECURE` | +|------|------|----------| +| Jinja2 `ImmutableSandboxedEnvironment` baseline | Yes | Yes | +| References to explicitly provided dataset variables only | Yes | Yes | +| Standard Jinja built-in filter set | Yes | Subset only | +| Data Designer `jsonpath` filter | Yes | Yes | +| `import`, `macro`, `set`, `extends`, `block` support | Yes | No | +| Nested or recursive `for` loops | Yes | No | +| Unbounded AST complexity | Yes | No | +| Template context sanitized to JSON-compatible types before render | No | Yes | +| Empty, oversized, or built-in-like rendered output is permitted | Yes | No | ## What `SECURE` Adds on Top of Standard Jinja Sandbox The `SECURE` renderer uses a hardened environment implemented in `packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py`. Compared with the standard Jinja sandbox, it adds several additional controls: - **Record sanitization before render**: Template context is serialized and deserialized into basic JSON-compatible types before rendering. This reduces the chance that unexpected Python objects expose attributes or callables to the template. -- **Filter allowlist**: Only a limited set of filters is available. This includes a small set of built-in Jinja filters plus Data Designer's custom `jsonpath` filter. +- **Filter allowlist**: Only a limited set of filters is available. This includes a small set of built-in Jinja filters and the Data Designer `jsonpath` filter. - **Unsupported template features removed**: `SECURE` rejects `import`, `macro`, `set`, `extends`, and `block`. - **Loop restrictions**: Recursive loops and nested `for` loops are rejected. - **AST complexity limits**: Templates are statically analyzed and rejected if they exceed the current complexity thresholds of 600 AST nodes or depth 10. diff --git a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py index 791b96141..9f0ca59b7 100644 --- a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py +++ b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py @@ -412,6 +412,7 @@ def __init__( super().__init__(autoescape=False, **kwargs) self.allowed_references = allowed_references if allowed_references else [] self._prefer_dict_key_access = prefer_dict_key_access + self.filters["jsonpath"] = jsonpath_jinja_filter def getattr(self, obj: Any, attribute: str) -> Any: if self._prefer_dict_key_access and isinstance(obj, dict) and attribute in obj: diff --git a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py index 1c07ae780..66e20b20e 100644 --- a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py +++ b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py @@ -6,6 +6,7 @@ from data_designer.config.run_config import JinjaRenderingEngine from data_designer.engine.processing.ginja.environment import ( ALLOWED_JINJA_FILTERS, + NativeJinjaSandboxEnvironment, UserTemplateSandboxEnvironment, WithJinja2UserTemplateRendering, is_jinja_template, @@ -97,6 +98,12 @@ def test_jsonpath_jinja_filter(jsonpath_query, expected_result): assert jsonpath_jinja_filter(TEST_RECORD, jsonpath_query) == expected_result +def test_native_jinja_sandbox_environment_supports_jsonpath_filter() -> None: + env = NativeJinjaSandboxEnvironment(allowed_references=list(TEST_RECORD.keys())) + + assert env.render_template('{{ field_c | jsonpath("$.sub_a.foo[:2]") }}', TEST_RECORD) == str([1, 2]) + + @pytest.mark.parametrize( "jinja_template,expected_result", [ From 72477db97d8a5ea645ca40dd79ec3f1286265ee8 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 12:58:09 -0400 Subject: [PATCH 07/13] docs: refine jinja security guide --- docs/concepts/security.md | 129 +++++++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 10 deletions(-) diff --git a/docs/concepts/security.md b/docs/concepts/security.md index 0ec671c92..5c92ad335 100644 --- a/docs/concepts/security.md +++ b/docs/concepts/security.md @@ -49,16 +49,125 @@ run_config = dd.RunConfig( ## What `SECURE` Adds on Top of Standard Jinja Sandbox -The `SECURE` renderer uses a hardened environment implemented in `packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py`. Compared with the standard Jinja sandbox, it adds several additional controls: - -- **Record sanitization before render**: Template context is serialized and deserialized into basic JSON-compatible types before rendering. This reduces the chance that unexpected Python objects expose attributes or callables to the template. -- **Filter allowlist**: Only a limited set of filters is available. This includes a small set of built-in Jinja filters and the Data Designer `jsonpath` filter. -- **Unsupported template features removed**: `SECURE` rejects `import`, `macro`, `set`, `extends`, and `block`. -- **Loop restrictions**: Recursive loops and nested `for` loops are rejected. -- **AST complexity limits**: Templates are statically analyzed and rejected if they exceed the current complexity thresholds of 600 AST nodes or depth 10. -- **`self` references blocked**: Templates cannot reference `self`, which reduces access to template internals. -- **Rendered output guards**: Empty output is rejected, very large output is rejected, and rendered strings that look like Python built-in or function representations are rejected. -- **Sanitized user-facing errors**: At the engine boundary, most template errors are normalized to a generic invalid-template message instead of surfacing internal exception details. +The `SECURE` renderer uses a hardened environment implemented in the [renderer source file on GitHub](https://github.com/NVIDIA-NeMo/DataDesigner/blob/v0.5.6/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py). Compared with the standard Jinja sandbox, it adds several additional controls. + +### Record Sanitization Before Render + +Before rendering, `SECURE` forces template context through a JSON-compatible serialization step. That means remote templates operate on plain data, not arbitrary Python objects. + +```python +# Intended shape for remote template context +record = { + "user": { + "name": "alice", + "roles": ["admin", "reviewer"], + } +} +``` + +```python +# Not the kind of server-side object SECURE wants to expose directly +record = { + "user": SomePythonObject(...), +} +``` + +In a remote execution setting, that matters because rich Python objects can expose attributes, methods, or descriptors that were never meant to be reachable from user-authored templates. The relevant history here is mostly Jinja sandbox-escape CVEs rather than Python interpreter CVEs: Jinja's [sandbox security considerations](https://jinja.palletsprojects.com/en/stable/sandbox/) explicitly note that the sandbox is not a complete security boundary, and fixes for untrusted-template execution have included [`str.format` sandbox escape (CVE-2016-10745)](https://nvd.nist.gov/vuln/detail/CVE-2016-10745), [`str.format_map` sandbox escape (CVE-2019-10906)](https://github.com/advisories/GHSA-462w-v97r-4m45), [indirect `str.format` reference escape (CVE-2024-56326)](https://nvd.nist.gov/vuln/detail/CVE-2024-56326), and [`|attr`-based access to `format` (CVE-2025-27516)](https://nvd.nist.gov/vuln/detail/CVE-2025-27516). The broader `instance -> __class__ -> mro -> modules -> os` style chain is better understood as server-side template injection and sandbox-escape technique literature; PortSwigger's [server-side template injection research](https://portswigger.net/research/server-side-template-injection) is a useful reference for that model. + +### Filter Allowlist + +`SECURE` does not expose the full Jinja filter surface. It keeps a small approved subset plus the Data Designer `jsonpath` filter. + +```jinja +{{ payload | jsonpath("$.customer.name") }} +``` + +```jinja +{{ items | join(", ") }} +``` + +The first example is supported. The second is broader Jinja behavior that `NATIVE` permits but `SECURE` intentionally rejects. In a shared engine, narrowing the filter surface reduces the number of operations that user templates can compose into server-side execution. + +### Template Features Removed + +`SECURE` rejects `import`, `macro`, `set`, `extends`, and `block`. + +```jinja +{% macro render_name(name) %}{{ name }}{% endmacro %} +{{ render_name(customer_name) }} +``` + +```jinja +{% set temp = user_id %} +{{ temp }} +``` + +Those features are useful in trusted authoring environments, but they also make user templates more expressive and stateful. In a remote execution model, `SECURE` intentionally narrows the language so templates stay closer to data interpolation than to a reusable programming layer. + +### Loop Restrictions + +`SECURE` rejects recursive loops and nested `for` loops. + +```jinja +{% for row in rows %} + {% for item in row %} + {{ item }} + {% endfor %} +{% endfor %} +``` + +Nested and recursive loops are especially risky in shared execution because they can amplify compute cost and output size in ways that are hard to reason about from the outside. + +### AST Complexity Limits + +`SECURE` statically analyzes the parsed Jinja AST and rejects templates that exceed the current limits of 600 nodes or depth 10. + +```jinja +{% if a %} + {% if b %} + {% if c %} + {{ value }} + {% endif %} + {% endif %} +{% endif %} +``` + +This is not about any one feature being unsafe by itself. It is about limiting how much control flow and composition untrusted templates can pack into a single server-side render operation. + +### `self` References Blocked + +`SECURE` rejects references to `self`. + +```jinja +{{ self }} +``` + +The point is to avoid exposing template internals back to the submitter. In a remote setting, even accidental access to those internals is unnecessary surface area. + +### Rendered Output Guards + +`SECURE` validates rendered output after template execution. It rejects empty output, very large output, and strings that look like Python built-in or function representations. + +```jinja +{{ "" }} +``` + +```text + + +``` + +These checks matter because not all bad outcomes come from parse-time behavior. Some templates are syntactically valid but still produce output that is clearly broken, oversized, or revealing internal implementation details. + +### Sanitized User-Facing Errors + +At the engine boundary, `SECURE` normalizes most template failures into a generic invalid-template message. + +```text +User provided prompt generation template is invalid. +``` + +That matters in remote execution because exception details can leak information about server-side implementation, supported objects, or internal execution paths that untrusted users do not need to see. These controls exist because the standard sandbox is a good baseline, but shared-service deployments need a narrower and more defensive execution model. From fa11435d759ccc36f39a5ceafbf4e5d2240aa47a Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 13:07:15 -0400 Subject: [PATCH 08/13] fix: allow upper in secure jinja --- docs/concepts/security.md | 22 ++++++++++--------- .../engine/processing/ginja/environment.py | 1 + .../processing/ginja/test_environment.py | 4 ++++ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/concepts/security.md b/docs/concepts/security.md index 5c92ad335..1b98bd1a9 100644 --- a/docs/concepts/security.md +++ b/docs/concepts/security.md @@ -72,21 +72,23 @@ record = { } ``` -In a remote execution setting, that matters because rich Python objects can expose attributes, methods, or descriptors that were never meant to be reachable from user-authored templates. The relevant history here is mostly Jinja sandbox-escape CVEs rather than Python interpreter CVEs: Jinja's [sandbox security considerations](https://jinja.palletsprojects.com/en/stable/sandbox/) explicitly note that the sandbox is not a complete security boundary, and fixes for untrusted-template execution have included [`str.format` sandbox escape (CVE-2016-10745)](https://nvd.nist.gov/vuln/detail/CVE-2016-10745), [`str.format_map` sandbox escape (CVE-2019-10906)](https://github.com/advisories/GHSA-462w-v97r-4m45), [indirect `str.format` reference escape (CVE-2024-56326)](https://nvd.nist.gov/vuln/detail/CVE-2024-56326), and [`|attr`-based access to `format` (CVE-2025-27516)](https://nvd.nist.gov/vuln/detail/CVE-2025-27516). The broader `instance -> __class__ -> mro -> modules -> os` style chain is better understood as server-side template injection and sandbox-escape technique literature; PortSwigger's [server-side template injection research](https://portswigger.net/research/server-side-template-injection) is a useful reference for that model. +In a remote execution setting, exposing rich Python objects increases the risk of attribute- and method-based sandbox escapes. Jinja's [sandbox security considerations](https://jinja.palletsprojects.com/en/stable/sandbox/) note that the sandbox is not a complete security boundary, and past escapes have included [`str.format` (CVE-2016-10745)](https://nvd.nist.gov/vuln/detail/CVE-2016-10745), [`str.format_map` (CVE-2019-10906)](https://github.com/advisories/GHSA-462w-v97r-4m45), [indirect `str.format` references (CVE-2024-56326)](https://nvd.nist.gov/vuln/detail/CVE-2024-56326), and [`|attr`-based access to `format` (CVE-2025-27516)](https://nvd.nist.gov/vuln/detail/CVE-2025-27516); PortSwigger's [server-side template injection research](https://portswigger.net/research/server-side-template-injection) covers the broader object-traversal pattern. ### Filter Allowlist -`SECURE` does not expose the full Jinja filter surface. It keeps a small approved subset plus the Data Designer `jsonpath` filter. +`SECURE` keeps only a small approved subset of Jinja filters plus the Data Designer `jsonpath` filter. If a filter is not on that allowlist, the template is rejected. Common excluded filters are: -```jinja -{{ payload | jsonpath("$.customer.name") }} -``` +| Disallowed filters | Why they are excluded in `SECURE` | +| --- | --- | +| `attr`, `xmlattr` | These add dynamic attribute lookup or attribute-name construction, which widens the object-traversal surface in untrusted templates. | +| `map`, `select`, `reject`, `selectattr`, `rejectattr`, `groupby`, `batch`, `slice`, `sum` | These make templates behave more like a data-processing language and can multiply compute across large inputs. | +| `join`, `format`, `indent`, `wordwrap`, `center`, `filesizeformat` | These expand presentation and composition logic inside the template. `SECURE` keeps formatting logic narrow so templates stay close to interpolation. | +| `default`, `d`, `dictsort`, `count`, `wordcount`, `pprint`, `tojson` | These encourage fallback logic, secondary data shaping, or debug-style output inside the template rather than in the engine or config layer. | +| `safe`, `striptags`, `urlize` | These are primarily HTML-oriented output transforms and are unnecessary for server-side dataset rendering. | -```jinja -{{ items | join(", ") }} -``` +Some omitted convenience filters, such as the `e` alias for `escape`, are excluded because `SECURE` uses a small explicit allowlist. The current implementation does not assign each omitted filter its own separate security rationale. -The first example is supported. The second is broader Jinja behavior that `NATIVE` permits but `SECURE` intentionally rejects. In a shared engine, narrowing the filter surface reduces the number of operations that user templates can compose into server-side execution. +Use `NATIVE` when full Jinja filter compatibility matters more than the additional restrictions used for untrusted template execution. ### Template Features Removed @@ -132,7 +134,7 @@ Nested and recursive loops are especially risky in shared execution because they {% endif %} ``` -This is not about any one feature being unsafe by itself. It is about limiting how much control flow and composition untrusted templates can pack into a single server-side render operation. +This is not about any one feature being unsafe by itself. It is about limiting how much control flow and composition untrusted templates can pack into a single server-side render operation, which helps prevent compute bombs in shared execution. ### `self` References Blocked diff --git a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py index 9f0ca59b7..bc7c5378d 100644 --- a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py +++ b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py @@ -57,6 +57,7 @@ "trim", "truncate", "unique", + "upper", "urlencode", ## Custom Filters "jsonpath", diff --git a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py index 66e20b20e..2c79599ea 100644 --- a/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py +++ b/packages/data-designer-engine/tests/engine/processing/ginja/test_environment.py @@ -104,6 +104,10 @@ def test_native_jinja_sandbox_environment_supports_jsonpath_filter() -> None: assert env.render_template('{{ field_c | jsonpath("$.sub_a.foo[:2]") }}', TEST_RECORD) == str([1, 2]) +def test_user_template_sandbox_environment_supports_upper_filter(stub_sandbox_env) -> None: + assert stub_sandbox_env.safe_render("{{ field_y | upper }}", TEST_RECORD) == "FOO" + + @pytest.mark.parametrize( "jinja_template,expected_result", [ From ed7a89a611330aa5b9ea82e7ca5726df8c58981e Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 13:12:57 -0400 Subject: [PATCH 09/13] feat: log jinja rendering mode at startup --- .../data_designer/interface/data_designer.py | 11 ++- .../tests/interface/test_data_designer.py | 82 ++++++++++++++++++- 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/packages/data-designer/src/data_designer/interface/data_designer.py b/packages/data-designer/src/data_designer/interface/data_designer.py index e487074a5..ae5f30b36 100644 --- a/packages/data-designer/src/data_designer/interface/data_designer.py +++ b/packages/data-designer/src/data_designer/interface/data_designer.py @@ -25,7 +25,7 @@ ModelProvider, ) from data_designer.config.preview_results import PreviewResults -from data_designer.config.run_config import RunConfig +from data_designer.config.run_config import JinjaRenderingEngine, RunConfig from data_designer.config.utils.constants import ( DEFAULT_NUM_RECORDS, MANAGED_ASSETS_PATH, @@ -65,7 +65,7 @@ DataDesignerProfilingError, ) from data_designer.interface.results import DatasetCreationResults -from data_designer.logging import RandomEmoji, configure_logging +from data_designer.logging import LOG_INDENT, RandomEmoji, configure_logging from data_designer.plugins.plugin import PluginType from data_designer.plugins.registry import PluginRegistry @@ -217,6 +217,7 @@ def create( DataDesignerProfilingError: If an error occurs during dataset profiling. """ logger.info("🎨 Creating Data Designer dataset") + self._log_jinja_rendering_engine_mode() resource_provider = self._create_resource_provider(dataset_name, config_builder) @@ -288,6 +289,7 @@ def preview( DataDesignerProfilingError: If an error occurs during preview dataset profiling. """ logger.info(f"{RandomEmoji.previewing()} Preview generation in progress") + self._log_jinja_rendering_engine_mode() resource_provider = self._create_resource_provider("preview-dataset", config_builder) try: @@ -333,6 +335,11 @@ def preview( dataset_metadata=dataset_metadata, ) + def _log_jinja_rendering_engine_mode(self) -> None: + engine = JinjaRenderingEngine(self._run_config.jinja_rendering_engine) + icon = "🔒" if engine == JinjaRenderingEngine.SECURE else "🔓" + logger.info(f"{LOG_INDENT}{icon} Jinja rendering engine: {engine.value}") + def validate(self, config_builder: DataDesignerConfigBuilder) -> None: """Validate the Data Designer configuration as defined by the DataDesignerConfigBuilder with the configured engine components (SecretResolver, SeedReaders, etc.). diff --git a/packages/data-designer/tests/interface/test_data_designer.py b/packages/data-designer/tests/interface/test_data_designer.py index 81db7722d..7798fc71a 100644 --- a/packages/data-designer/tests/interface/test_data_designer.py +++ b/packages/data-designer/tests/interface/test_data_designer.py @@ -20,7 +20,7 @@ from data_designer.config.errors import InvalidConfigError from data_designer.config.models import ModelProvider from data_designer.config.processors import DropColumnsProcessorConfig -from data_designer.config.run_config import RunConfig +from data_designer.config.run_config import JinjaRenderingEngine, RunConfig from data_designer.config.sampler_params import CategorySamplerParams, DatetimeSamplerParams, SamplerType from data_designer.config.seed import IndexRange, PartitionBlock, SamplingStrategy from data_designer.config.seed_source import ( @@ -703,6 +703,86 @@ def test_preview_raises_generation_error_when_dataset_is_empty( data_designer.preview(stub_sampler_only_config_builder, num_records=1) +def test_create_logs_secure_jinja_rendering_mode( + stub_artifact_path: Path, + stub_model_providers: list[ModelProvider], + stub_sampler_only_config_builder: DataDesignerConfigBuilder, + stub_managed_assets_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + with patch.object(dd_mod, "get_default_provider_name", return_value="stub-model-provider"): + data_designer = DataDesigner( + artifact_path=stub_artifact_path, + model_providers=stub_model_providers, + secret_resolver=PlaintextResolver(), + managed_assets_path=stub_managed_assets_path, + ) + data_designer.set_run_config(RunConfig(jinja_rendering_engine=JinjaRenderingEngine.SECURE)) + + with ( + patch.object(data_designer, "_create_resource_provider") as mock_resource_provider_method, + patch.object(data_designer, "_create_dataset_builder") as mock_builder_method, + patch.object(data_designer, "_create_dataset_profiler") as mock_profiler_method, + ): + mock_resource_provider = MagicMock() + mock_resource_provider.get_dataset_metadata.return_value = {} + mock_resource_provider_method.return_value = mock_resource_provider + + mock_builder = MagicMock() + mock_builder.build.return_value = None + mock_builder.task_traces = [] + mock_builder.artifact_storage.load_dataset_with_dropped_columns.return_value = lazy.pd.DataFrame({"col": [1]}) + mock_builder_method.return_value = mock_builder + + mock_profiler = MagicMock() + mock_profiler.profile_dataset.return_value = None + mock_profiler_method.return_value = mock_profiler + + data_designer.create(stub_sampler_only_config_builder, num_records=1) + + assert "🔒 Jinja rendering engine: secure" in capsys.readouterr().err + + +def test_preview_logs_native_jinja_rendering_mode( + stub_artifact_path: Path, + stub_model_providers: list[ModelProvider], + stub_sampler_only_config_builder: DataDesignerConfigBuilder, + stub_managed_assets_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + with patch.object(dd_mod, "get_default_provider_name", return_value="stub-model-provider"): + data_designer = DataDesigner( + artifact_path=stub_artifact_path, + model_providers=stub_model_providers, + secret_resolver=PlaintextResolver(), + managed_assets_path=stub_managed_assets_path, + ) + data_designer.set_run_config(RunConfig(jinja_rendering_engine=JinjaRenderingEngine.NATIVE)) + + with ( + patch.object(data_designer, "_create_resource_provider") as mock_resource_provider_method, + patch.object(data_designer, "_create_dataset_builder") as mock_builder_method, + patch.object(data_designer, "_create_dataset_profiler") as mock_profiler_method, + ): + mock_resource_provider = MagicMock() + mock_resource_provider.get_dataset_metadata.return_value = {} + mock_resource_provider_method.return_value = mock_resource_provider + + mock_builder = MagicMock() + mock_builder.build_preview.return_value = lazy.pd.DataFrame({"col": [1]}) + mock_builder.process_preview.return_value = lazy.pd.DataFrame({"col": [1]}) + mock_builder.artifact_storage.list_processor_names.return_value = [] + mock_builder_method.return_value = mock_builder + + mock_profiler = MagicMock() + mock_profiler.profile_dataset.return_value = None + mock_profiler_method.return_value = mock_profiler + + data_designer.preview(stub_sampler_only_config_builder, num_records=1) + + assert "🔓 Jinja rendering engine: native" in capsys.readouterr().err + + def test_preview_datetime_single_record_returns_iso8601( stub_artifact_path, stub_model_providers, stub_model_configs, stub_managed_assets_path ): From f6ad4febc5deb07588269ad313d74d3c52d2db44 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 13:18:48 -0400 Subject: [PATCH 10/13] chore: use house icon for native jinja logs --- .../data-designer/src/data_designer/interface/data_designer.py | 2 +- packages/data-designer/tests/interface/test_data_designer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/data-designer/src/data_designer/interface/data_designer.py b/packages/data-designer/src/data_designer/interface/data_designer.py index ae5f30b36..7913bbbee 100644 --- a/packages/data-designer/src/data_designer/interface/data_designer.py +++ b/packages/data-designer/src/data_designer/interface/data_designer.py @@ -337,7 +337,7 @@ def preview( def _log_jinja_rendering_engine_mode(self) -> None: engine = JinjaRenderingEngine(self._run_config.jinja_rendering_engine) - icon = "🔒" if engine == JinjaRenderingEngine.SECURE else "🔓" + icon = "🔒" if engine == JinjaRenderingEngine.SECURE else "🏠" logger.info(f"{LOG_INDENT}{icon} Jinja rendering engine: {engine.value}") def validate(self, config_builder: DataDesignerConfigBuilder) -> None: diff --git a/packages/data-designer/tests/interface/test_data_designer.py b/packages/data-designer/tests/interface/test_data_designer.py index 7798fc71a..11e7f82c3 100644 --- a/packages/data-designer/tests/interface/test_data_designer.py +++ b/packages/data-designer/tests/interface/test_data_designer.py @@ -780,7 +780,7 @@ def test_preview_logs_native_jinja_rendering_mode( data_designer.preview(stub_sampler_only_config_builder, num_records=1) - assert "🔓 Jinja rendering engine: native" in capsys.readouterr().err + assert "🏠 Jinja rendering engine: native" in capsys.readouterr().err def test_preview_datetime_single_record_returns_iso8601( From a630d1bbca0f450f6cb1ed3c7067a2826a7b6e6b Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 13:39:16 -0400 Subject: [PATCH 11/13] fix: parse jsonpath in expression dependencies --- .../src/data_designer/engine/sampling_gen/jinja_utils.py | 4 ++-- .../tests/engine/sampling_gen/test_jinja_utils.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py index 9aa0210ab..ccf440ca2 100644 --- a/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py +++ b/packages/data-designer-engine/src/data_designer/engine/sampling_gen/jinja_utils.py @@ -7,11 +7,11 @@ from typing import TYPE_CHECKING, Any from jinja2 import meta -from jinja2.sandbox import ImmutableSandboxedEnvironment import data_designer.lazy_heavy_imports as lazy from data_designer.config.run_config import JinjaRenderingEngine from data_designer.engine.processing.ginja.environment import ( + NativeJinjaSandboxEnvironment, WithJinja2UserTemplateRendering, ) @@ -70,4 +70,4 @@ def to_column(self, dataframe: pd.DataFrame) -> list[Any]: def extract_column_names_from_expression(expr: str) -> set[str]: """Extract valid column names from the given expression.""" - return meta.find_undeclared_variables(ImmutableSandboxedEnvironment().parse("{{ " + expr + " }}")) + return meta.find_undeclared_variables(NativeJinjaSandboxEnvironment().parse("{{ " + expr + " }}")) diff --git a/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py b/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py index efea798e3..bd050f7e1 100644 --- a/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py +++ b/packages/data-designer-engine/tests/engine/sampling_gen/test_jinja_utils.py @@ -29,6 +29,7 @@ ("some_dude.age + 1", {"some_dude"}), ("'I\\'m a string' + i_am_a_var", {"i_am_a_var"}), ('"I am a string" + i_am_a_var', {"i_am_a_var"}), + ('data | jsonpath("$.key")', {"data"}), ], ) def test_extract_column_names_from_expression(expr: str, column_names: set[str]) -> None: From c4bdcd6acffcbfe338ed3e790b99b05bbebbf74e Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 13:49:40 -0400 Subject: [PATCH 12/13] test: stabilize jinja mode log assertions --- .../data-designer/tests/interface/test_data_designer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/data-designer/tests/interface/test_data_designer.py b/packages/data-designer/tests/interface/test_data_designer.py index 11e7f82c3..dc56b1a74 100644 --- a/packages/data-designer/tests/interface/test_data_designer.py +++ b/packages/data-designer/tests/interface/test_data_designer.py @@ -708,7 +708,6 @@ def test_create_logs_secure_jinja_rendering_mode( stub_model_providers: list[ModelProvider], stub_sampler_only_config_builder: DataDesignerConfigBuilder, stub_managed_assets_path: Path, - capsys: pytest.CaptureFixture[str], ) -> None: with patch.object(dd_mod, "get_default_provider_name", return_value="stub-model-provider"): data_designer = DataDesigner( @@ -720,6 +719,7 @@ def test_create_logs_secure_jinja_rendering_mode( data_designer.set_run_config(RunConfig(jinja_rendering_engine=JinjaRenderingEngine.SECURE)) with ( + patch.object(dd_mod.logger, "info") as mock_info, patch.object(data_designer, "_create_resource_provider") as mock_resource_provider_method, patch.object(data_designer, "_create_dataset_builder") as mock_builder_method, patch.object(data_designer, "_create_dataset_profiler") as mock_profiler_method, @@ -740,7 +740,7 @@ def test_create_logs_secure_jinja_rendering_mode( data_designer.create(stub_sampler_only_config_builder, num_records=1) - assert "🔒 Jinja rendering engine: secure" in capsys.readouterr().err + assert any("🔒 Jinja rendering engine: secure" in call.args[0] for call in mock_info.call_args_list) def test_preview_logs_native_jinja_rendering_mode( @@ -748,7 +748,6 @@ def test_preview_logs_native_jinja_rendering_mode( stub_model_providers: list[ModelProvider], stub_sampler_only_config_builder: DataDesignerConfigBuilder, stub_managed_assets_path: Path, - capsys: pytest.CaptureFixture[str], ) -> None: with patch.object(dd_mod, "get_default_provider_name", return_value="stub-model-provider"): data_designer = DataDesigner( @@ -760,6 +759,7 @@ def test_preview_logs_native_jinja_rendering_mode( data_designer.set_run_config(RunConfig(jinja_rendering_engine=JinjaRenderingEngine.NATIVE)) with ( + patch.object(dd_mod.logger, "info") as mock_info, patch.object(data_designer, "_create_resource_provider") as mock_resource_provider_method, patch.object(data_designer, "_create_dataset_builder") as mock_builder_method, patch.object(data_designer, "_create_dataset_profiler") as mock_profiler_method, @@ -780,7 +780,7 @@ def test_preview_logs_native_jinja_rendering_mode( data_designer.preview(stub_sampler_only_config_builder, num_records=1) - assert "🏠 Jinja rendering engine: native" in capsys.readouterr().err + assert any("🏠 Jinja rendering engine: native" in call.args[0] for call in mock_info.call_args_list) def test_preview_datetime_single_record_returns_iso8601( From 0e159d41b06f898dff8fd480ba8c62a9134560c8 Mon Sep 17 00:00:00 2001 From: "Eric W. Tramel" Date: Fri, 17 Apr 2026 14:01:32 -0400 Subject: [PATCH 13/13] refactor: tighten jinja environment error handling --- .../engine/processing/ginja/environment.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py index bc7c5378d..072e09dc3 100644 --- a/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py +++ b/packages/data-designer-engine/src/data_designer/engine/processing/ginja/environment.py @@ -280,9 +280,11 @@ def validate_template(self, user_template: str) -> None: self._assert_template_ast_complexity(ast) self._assert_template_has_no_self_reference(ast) self._assert_template_has_valid_references(ast) + except UserTemplateError: + raise except Exception as exception: maybe_handle_missing_filter_exception(exception, available_jinja_filters=list(self.filters.keys())) - raise exception + raise def _assert_rendered_text_length(self, rendered_text: str) -> None: """Check against the length of the rendered string.""" @@ -427,9 +429,11 @@ def validate_template(self, user_template: str) -> None: unallowed_vars = set(template_vars) - set(self.allowed_references) if len(unallowed_vars) > 0: raise UserTemplateError(f"Unknown variable references in Jinja template: {unallowed_vars}") + except UserTemplateError: + raise except Exception as exception: maybe_handle_missing_filter_exception(exception, available_jinja_filters=list(self.filters.keys())) - raise exception + raise def render_template( self, @@ -495,10 +499,16 @@ def my_func(self, user_template: str, records: list[dict]): _template_render_fn: Callable def _get_jinja_rendering_engine(self) -> JinjaRenderingEngine: - if hasattr(self, "_jinja_rendering_engine"): - return JinjaRenderingEngine(getattr(self, "_jinja_rendering_engine")) - if hasattr(self, "_resource_provider"): - return JinjaRenderingEngine(self._resource_provider.run_config.jinja_rendering_engine) + engine = getattr(self, "_jinja_rendering_engine", None) + if engine is not None: + return JinjaRenderingEngine(engine) + + resource_provider = getattr(self, "_resource_provider", None) + if resource_provider is not None: + return JinjaRenderingEngine(resource_provider.run_config.jinja_rendering_engine) + + # The mixin predates the RunConfig toggle, so preserve the historical + # secure-by-default behavior when no explicit engine is wired in. return JinjaRenderingEngine.SECURE def _create_render_environment(