Skip to content

Commit 7b6f896

Browse files
committed
RSPEED-2529: harden Jinja2 system prompt rendering
Use SandboxedEnvironment for defense-in-depth and catch TemplateSyntaxError so malformed operator prompts surface a clear ValueError instead of an opaque Jinja2 traceback on every request. Signed-off-by: Major Hayden <major@redhat.com>
1 parent 4380346 commit 7b6f896

File tree

4 files changed

+171
-89
lines changed

4 files changed

+171
-89
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ dependencies = [
6767
"azure-core>=1.38.0",
6868
"azure-identity>=1.21.0",
6969
"pyasn1>=0.6.2",
70+
# Used for system prompt template variable rendering
71+
"jinja2>=3.1.0",
7072
]
7173

7274

src/app/endpoints/rlsapi_v1.py

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
from the RHEL Lightspeed Command Line Assistant (CLA).
55
"""
66

7+
import functools
78
import time
89
from datetime import datetime
910
from typing import Annotated, Any, Optional, cast
1011

12+
import jinja2
13+
from jinja2.sandbox import SandboxedEnvironment
1114
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
1215
from llama_stack_api.openai_responses import OpenAIResponseObject
1316
from llama_stack_client import APIConnectionError, APIStatusError, RateLimitError
@@ -102,44 +105,66 @@ def _get_rh_identity_context(request: Request) -> tuple[str, str]:
102105

103106

104107
def _build_instructions(systeminfo: RlsapiV1SystemInfo) -> str:
105-
"""Build LLM instructions incorporating date and system context.
108+
"""Build LLM instructions by rendering the system prompt as a Jinja2 template.
106109
107-
Enhances the default system prompt with today's date and RHEL system
108-
information to provide the LLM with relevant context about the user's
109-
environment and current time.
110+
The base prompt is rendered with the context variables ``date``, ``os``,
111+
``version``, and ``arch``. Prompts without template markers pass through
112+
unchanged. The compiled template is cached after the first call.
110113
111114
Args:
112115
systeminfo: System information from the client (OS, version, arch).
113116
114117
Returns:
115-
Instructions string for the LLM, with date and system context.
118+
The rendered instructions string for the LLM.
116119
"""
117-
base_prompt = _get_base_prompt()
118120
date_today = datetime.now().strftime("%B %d, %Y")
119121

120-
context_parts = []
121-
if systeminfo.os:
122-
context_parts.append(f"OS: {systeminfo.os}")
123-
if systeminfo.version:
124-
context_parts.append(f"Version: {systeminfo.version}")
125-
if systeminfo.arch:
126-
context_parts.append(f"Architecture: {systeminfo.arch}")
122+
return _get_prompt_template().render(
123+
date=date_today,
124+
os=systeminfo.os or "",
125+
version=systeminfo.version or "",
126+
arch=systeminfo.arch or "",
127+
)
128+
127129

128-
if not context_parts:
129-
return f"{base_prompt}\n\nToday's date: {date_today}"
130+
@functools.lru_cache(maxsize=1)
131+
def _get_prompt_template() -> jinja2.Template:
132+
"""Compile and cache the system prompt as a Jinja2 template.
130133
131-
system_context = ", ".join(context_parts)
132-
return f"{base_prompt}\n\nToday's date: {date_today}\n\nUser's system: {system_context}"
134+
The template is compiled once on first call and reused for all subsequent
135+
requests since the system prompt does not change at runtime.
133136
137+
Uses SandboxedEnvironment to restrict template capabilities. The template
138+
source is admin-controlled (config file), but sandboxing provides
139+
defense-in-depth: if the configuration surface ever expands (e.g. prompts
140+
from a database or API), unsandboxed templates could expose Python
141+
internals via Jinja2's introspection (``__class__``, ``__subclasses__``).
134142
135-
def _get_base_prompt() -> str:
136-
"""Get the base system prompt with configuration fallback."""
137-
if (
138-
configuration.customization is not None
143+
TemplateSyntaxError is caught and re-raised as ValueError so that
144+
malformed prompts produce a clear, actionable error message instead of
145+
an opaque Jinja2 traceback on every request. Because lru_cache does not
146+
cache exceptions, the error will repeat until the admin fixes the config.
147+
"""
148+
# SandboxedEnvironment disables dangerous operations (getattr on dunders,
149+
# calls to unsafe methods) while still supporting the template variables
150+
# and conditionals used in system prompts ({{ date }}, {% if os %}, etc.).
151+
env = SandboxedEnvironment()
152+
153+
prompt = (
154+
configuration.customization.system_prompt
155+
if configuration.customization is not None
139156
and configuration.customization.system_prompt is not None
140-
):
141-
return configuration.customization.system_prompt
142-
return constants.DEFAULT_SYSTEM_PROMPT
157+
else constants.DEFAULT_SYSTEM_PROMPT
158+
)
159+
160+
try:
161+
return env.from_string(prompt)
162+
except jinja2.TemplateSyntaxError as exc:
163+
# Surface the exact syntax problem so operators can fix their config
164+
# without digging through a full Jinja2 stack trace.
165+
raise ValueError(
166+
f"System prompt contains invalid Jinja2 syntax: {exc}"
167+
) from exc
143168

144169

145170
async def _get_default_model_id() -> str:

tests/unit/app/endpoints/test_rlsapi_v1.py

Lines changed: 118 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
# pylint: disable=unused-argument
55

66
import re
7-
from typing import Any, Optional
7+
from collections.abc import Callable
8+
from typing import Any
89

910
import pytest
1011
from fastapi import HTTPException, status
@@ -17,6 +18,7 @@
1718
AUTH_DISABLED,
1819
_build_instructions,
1920
_get_default_model_id,
21+
_get_prompt_template,
2022
_get_rh_identity_context,
2123
infer_endpoint,
2224
retrieve_simple_response,
@@ -39,6 +41,26 @@
3941
MOCK_AUTH: AuthTuple = ("mock_user_id", "mock_username", False, "mock_token")
4042

4143

44+
@pytest.fixture(autouse=True)
45+
def _clear_prompt_template_cache() -> None:
46+
"""Clear the lru_cache on _get_prompt_template between tests."""
47+
_get_prompt_template.cache_clear()
48+
49+
50+
@pytest.fixture(name="mock_custom_prompt")
51+
def mock_custom_prompt_fixture(mocker: MockerFixture) -> Callable[[str], None]:
52+
"""Factory fixture that patches configuration with a custom system prompt."""
53+
54+
def _set(prompt: str) -> None:
55+
mock_customization = mocker.Mock()
56+
mock_customization.system_prompt = prompt
57+
mock_config = mocker.Mock()
58+
mock_config.customization = mock_customization
59+
mocker.patch("app.endpoints.rlsapi_v1.configuration", mock_config)
60+
61+
return _set
62+
63+
4264
def _create_mock_request(mocker: MockerFixture, rh_identity: Any = None) -> Any:
4365
"""Create a mock FastAPI Request with optional RH Identity data."""
4466
mock_request = mocker.Mock()
@@ -140,93 +162,124 @@ def mock_generic_runtime_error_fixture(mocker: MockerFixture) -> None:
140162
# --- Test _build_instructions ---
141163

142164

143-
@pytest.mark.parametrize(
144-
("systeminfo_kwargs", "expected_contains", "expected_not_contains"),
145-
[
146-
pytest.param(
147-
{"os": "RHEL", "version": "9.3", "arch": "x86_64"},
148-
["OS: RHEL", "Version: 9.3", "Architecture: x86_64"],
149-
[],
150-
id="full_systeminfo",
151-
),
152-
pytest.param(
153-
{"os": "RHEL", "version": "", "arch": ""},
154-
["OS: RHEL"],
155-
["Version:", "Architecture:"],
156-
id="partial_systeminfo",
157-
),
158-
pytest.param(
159-
{},
160-
[constants.DEFAULT_SYSTEM_PROMPT],
161-
["OS:", "Version:", "Architecture:"],
162-
id="empty_systeminfo",
163-
),
164-
],
165-
)
166-
def test_build_instructions(
167-
systeminfo_kwargs: dict[str, str],
168-
expected_contains: list[str],
169-
expected_not_contains: list[str],
170-
) -> None:
171-
"""Test _build_instructions includes date and system info."""
172-
systeminfo = RlsapiV1SystemInfo(**systeminfo_kwargs)
165+
def test_build_instructions_default_prompt_passes_through() -> None:
166+
"""Test _build_instructions returns default prompt unchanged when no template vars."""
167+
systeminfo = RlsapiV1SystemInfo(os="RHEL", version="9.3", arch="x86_64")
173168
result = _build_instructions(systeminfo)
174169

175-
assert re.search(r"Today's date: \w+ \d{2}, \d{4}", result)
176-
for expected in expected_contains:
177-
assert expected in result
178-
for not_expected in expected_not_contains:
179-
assert not_expected not in result
180-
181-
182-
# --- Test _build_instructions with customization.system_prompt ---
170+
assert result == constants.DEFAULT_SYSTEM_PROMPT
183171

184172

185-
@pytest.mark.parametrize(
186-
("custom_prompt", "expected_prompt"),
187-
[
188-
pytest.param(
189-
"You are a RHEL expert.",
190-
"You are a RHEL expert.",
191-
id="customization_system_prompt_set",
192-
),
193-
pytest.param(
194-
None,
195-
constants.DEFAULT_SYSTEM_PROMPT,
196-
id="customization_system_prompt_none",
197-
),
198-
],
199-
)
200-
def test_build_instructions_with_customization(
201-
mocker: MockerFixture,
202-
custom_prompt: Optional[str],
203-
expected_prompt: str,
204-
) -> None:
205-
"""Test _build_instructions uses customization.system_prompt when set."""
173+
def test_build_instructions_with_customization(mocker: MockerFixture) -> None:
174+
"""Test _build_instructions uses customization.system_prompt with template vars."""
175+
template = "Expert assistant.\n\nDate: {{ date }}\nOS: {{ os }}"
206176
mock_customization = mocker.Mock()
207-
mock_customization.system_prompt = custom_prompt
177+
mock_customization.system_prompt = template
208178
mock_config = mocker.Mock()
209179
mock_config.customization = mock_customization
210180
mocker.patch("app.endpoints.rlsapi_v1.configuration", mock_config)
211181

212182
systeminfo = RlsapiV1SystemInfo(os="RHEL", version="9.3", arch="x86_64")
213183
result = _build_instructions(systeminfo)
214184

215-
assert expected_prompt in result
185+
assert "Expert assistant." in result
216186
assert "OS: RHEL" in result
187+
assert re.search(r"Date: \w+ \d{2}, \d{4}", result)
217188

218189

219190
def test_build_instructions_no_customization(mocker: MockerFixture) -> None:
220-
"""Test _build_instructions falls back when customization is None."""
191+
"""Test _build_instructions falls back to DEFAULT_SYSTEM_PROMPT."""
221192
mock_config = mocker.Mock()
222193
mock_config.customization = None
223194
mocker.patch("app.endpoints.rlsapi_v1.configuration", mock_config)
224195

225196
systeminfo = RlsapiV1SystemInfo()
226197
result = _build_instructions(systeminfo)
227198

228-
assert result.startswith(constants.DEFAULT_SYSTEM_PROMPT)
229-
assert re.search(r"Today's date: \w+ \d{2}, \d{4}", result)
199+
assert result == constants.DEFAULT_SYSTEM_PROMPT
200+
201+
202+
# --- Test Jinja2 template rendering ---
203+
204+
205+
def test_build_instructions_renders_jinja2_template(
206+
mock_custom_prompt: Callable[[str], None],
207+
) -> None:
208+
"""Test _build_instructions renders Jinja2 template variables instead of appending."""
209+
mock_custom_prompt(
210+
"You are an assistant.\n\nDate: {{ date }}\nOS: {{ os }} {{ version }} ({{ arch }})"
211+
)
212+
213+
systeminfo = RlsapiV1SystemInfo(os="RHEL", version="9.3", arch="x86_64")
214+
result = _build_instructions(systeminfo)
215+
216+
assert "OS: RHEL 9.3 (x86_64)" in result
217+
assert re.search(r"Date: \w+ \d{2}, \d{4}", result)
218+
assert "Today's date:" not in result
219+
assert "User's system:" not in result
220+
221+
222+
def test_build_instructions_jinja2_none_values_render_empty(
223+
mock_custom_prompt: Callable[[str], None],
224+
) -> None:
225+
"""Test that None system info values render as empty strings, not 'None'."""
226+
mock_custom_prompt("Assistant.\nOS={{ os }} VER={{ version }} ARCH={{ arch }}")
227+
228+
systeminfo = RlsapiV1SystemInfo()
229+
result = _build_instructions(systeminfo)
230+
231+
assert "None" not in result
232+
assert "OS= VER= ARCH=" in result
233+
234+
235+
def test_build_instructions_jinja2_conditionals(
236+
mock_custom_prompt: Callable[[str], None],
237+
) -> None:
238+
"""Test that Jinja2 conditionals work in system prompt templates."""
239+
mock_custom_prompt(
240+
"Assistant.{% if os %} OS: {{ os }}{% endif %}"
241+
"{% if version %} VER: {{ version }}{% endif %}"
242+
)
243+
244+
systeminfo = RlsapiV1SystemInfo(os="RHEL")
245+
result = _build_instructions(systeminfo)
246+
247+
assert "OS: RHEL" in result
248+
assert "VER:" not in result
249+
250+
251+
def test_build_instructions_plain_prompt_passes_through(
252+
mock_custom_prompt: Callable[[str], None],
253+
) -> None:
254+
"""Test that prompts without Jinja2 syntax pass through unchanged."""
255+
plain_prompt = "You are an expert RHEL assistant."
256+
mock_custom_prompt(plain_prompt)
257+
258+
systeminfo = RlsapiV1SystemInfo(os="RHEL", version="9.3", arch="x86_64")
259+
result = _build_instructions(systeminfo)
260+
261+
assert result == plain_prompt
262+
263+
264+
@pytest.mark.parametrize(
265+
"bad_template",
266+
[
267+
pytest.param("Hello {{ unclosed", id="unclosed_variable"),
268+
pytest.param("{% if %}", id="if_without_condition"),
269+
pytest.param("{% endfor %}", id="endfor_without_for"),
270+
],
271+
)
272+
def test_build_instructions_malformed_template_raises_value_error(
273+
mock_custom_prompt: Callable[[str], None],
274+
bad_template: str,
275+
) -> None:
276+
"""Test that invalid Jinja2 syntax in system prompt raises ValueError."""
277+
mock_custom_prompt(bad_template)
278+
279+
systeminfo = RlsapiV1SystemInfo(os="RHEL", version="9.3", arch="x86_64")
280+
281+
with pytest.raises(ValueError, match="invalid Jinja2 syntax"):
282+
_build_instructions(systeminfo)
230283

231284

232285
# --- Test _get_default_model_id ---

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)