fix: sandbox nested persona template rendering in evaluation prompts

Merge https://github.com/google/adk-python/pull/5266

### Link to Issue or Description of Change

**1. Link to an existing issue (if applicable):**

- Related: #5265

**2. Or, if no issue exists, describe the change:**

**Problem:**
Nested persona behavior strings were rendered through `render_string_filter`, which created a fresh Jinja template from persona-controlled content instead of consistently reusing a sandboxed environment. That allowed nested persona templates to execute outside the intended sandbox boundary in evaluation prompt construction.

Affected files:
- `src/google/adk/evaluation/simulation/llm_backed_user_simulator_prompts.py`
- `src/google/adk/evaluation/simulation/per_turn_user_simulator_quality_prompts.py`

**Solution:**
Render nested persona strings through `SandboxedEnvironment` and use `SandboxedEnvironment` for the per-turn evaluator prompt builder as well. This keeps supported nested placeholders such as `{{ stop_signal }}` working while blocking unsafe nested template access. Added regression tests for both the safe interpolation path and blocked unsafe attribute traversal.

### Testing Plan

**Unit Tests:**

- [x] I have added or updated unit tests for my change.
- [ ] All unit tests pass locally.

Passed in clean Linux Docker (`python:3.11-bookworm`):
- `uv sync --all-extras`
- `pytest tests/unittests/evaluation/simulation`
- Result: `70 passed`

Additional repo-wide validation:
- `pytest tests/unittests`
- Result on patched branch: `5326 passed, 1 skipped, 5 failed`
- The same 5 failures reproduce on unmodified `origin/main`
- Those failures are unrelated `tests/unittests/tools/test_skill_toolset.py` integration timeouts

**Manual End-to-End (E2E) Tests:**

- [x] Ran a live `adk web` regression test against the eval API in Linux Docker using a local non-LLM root agent.
- [x] Malicious nested persona template `{{ ''.__class__.__mro__ }}` was blocked during prompt construction with `jinja2.exceptions.SecurityError`.
- [x] A safe persona using nested `{{ stop_signal }}` placeholders did not raise `TemplateSyntaxError` or `SecurityError` and progressed beyond prompt rendering into a real Gemini model call.
- [x] The safe run did not fully complete because the test key hit `429 RESOURCE_EXHAUSTED`, but the absence of template errors and the subsequent model call confirm the sandboxed nested rendering path is functioning as intended.

### Checklist

- [x] I have read the [CONTRIBUTING.md](https://github.com/google/adk-python/blob/main/CONTRIBUTING.md) document.
- [x] I have performed a self-review of my own code.
- [ ] I have commented my code, particularly in hard-to-understand areas.
- [x] I have added tests that prove my fix is effective or that my feature works.
- [ ] New and existing unit tests pass locally with my changes.
- [x] I have manually tested my changes end-to-end.
- [x] Any dependent changes have been merged and published in downstream modules.

### Additional context

This is a small, focused fix intended to close the nested-template sandbox bypass path without changing the supported nested placeholder behavior used by existing personas.

Co-authored-by: George Weale <gweale@google.com>
COPYBARA_INTEGRATE_REVIEW=https://github.com/google/adk-python/pull/5266 from petrmarinec:fix-jinja-ssti-sandbox edc6364b6c2e1c076e0e22e5fccab60b19bf92bb
PiperOrigin-RevId: 933966100
This commit is contained in:
yyy
2026-06-17 15:48:21 -07:00
committed by Copybara-Service
parent 551580058d
commit 30493bae56
4 changed files with 111 additions and 6 deletions
@@ -185,7 +185,6 @@ def get_llm_backed_user_simulator_prompt(
"""Formats the prompt for the llm-backed user simulator"""
from jinja2 import DictLoader
from jinja2 import pass_context
from jinja2 import Template
from jinja2.sandbox import SandboxedEnvironment
templates = {
@@ -200,7 +199,7 @@ def get_llm_backed_user_simulator_prompt(
def _render_string_filter(context, template_string):
if not template_string:
return ""
return Template(template_string).render(context)
return template_env.from_string(template_string).render(context.get_all())
template_env.filters["render_string_filter"] = _render_string_filter
@@ -221,9 +221,8 @@ def get_per_turn_user_simulator_quality_prompt(
):
"""Formats the prompt for the per turn user simulator evaluator"""
from jinja2 import DictLoader
from jinja2 import Environment
from jinja2 import pass_context
from jinja2 import Template
from jinja2.sandbox import SandboxedEnvironment
templates = {
"verifier_instructions": (
@@ -232,13 +231,13 @@ def get_per_turn_user_simulator_quality_prompt(
)
),
}
template_env = Environment(loader=DictLoader(templates))
template_env = SandboxedEnvironment(loader=DictLoader(templates))
@pass_context
def _render_string_filter(context, template_string):
if not template_string:
return ""
return Template(template_string).render(context)
return template_env.from_string(template_string).render(context.get_all())
template_env.filters["render_string_filter"] = _render_string_filter
@@ -21,6 +21,7 @@ from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import g
from google.adk.evaluation.simulation.llm_backed_user_simulator_prompts import is_valid_user_simulator_template
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from jinja2.exceptions import SecurityError
import pytest
_MOCK_DEFAULT_TEMPLATE = textwrap.dedent("""\
@@ -208,6 +209,57 @@ class TestGetLlmBackedUserSimulatorPrompt:
test stop""").strip()
assert prompt == expected_prompt
def test_get_llm_backed_user_simulator_prompt_renders_persona_templates_in_sandbox(
self,
):
user_persona = UserPersona(
id="test_persona",
description="Test persona description",
behaviors=[
UserBehavior(
name="Behavior {{ stop_signal }}",
description="Description {{ stop_signal }}",
behavior_instructions=["instruction {{ stop_signal }}"],
violation_rubrics=["rubric 1"],
)
],
)
prompt = get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
user_persona=user_persona,
)
assert "## Behavior test stop" in prompt
assert "Description test stop" in prompt
assert " * instruction test stop" in prompt
def test_get_llm_backed_user_simulator_prompt_blocks_unsafe_persona_templates(
self,
):
user_persona = UserPersona(
id="test_persona",
description="Test persona description",
behaviors=[
UserBehavior(
name="{{ ''.__class__.__mro__ }}",
description="Test behavior description",
behavior_instructions=["instruction 1"],
violation_rubrics=["rubric 1"],
)
],
)
with pytest.raises(SecurityError):
get_llm_backed_user_simulator_prompt(
conversation_plan="test plan",
conversation_history="test history",
stop_signal="test stop",
user_persona=user_persona,
)
class TestIsValidUserSimulatorTemplate:
"""Test cases for is_valid_user_simulator_template."""
@@ -20,6 +20,8 @@ from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts im
from google.adk.evaluation.simulation.per_turn_user_simulator_quality_prompts import get_per_turn_user_simulator_quality_prompt
from google.adk.evaluation.simulation.user_simulator_personas import UserBehavior
from google.adk.evaluation.simulation.user_simulator_personas import UserPersona
from jinja2.exceptions import SecurityError
import pytest
_MOCK_DEFAULT_TEMPLATE = textwrap.dedent("""\
Default template
@@ -182,3 +184,56 @@ class TestGetPerTurnUserSimulatorQualityPrompt:
# Stop signal
stop""").strip()
assert prompt == expected_prompt
def test_get_per_turn_user_simulator_quality_prompt_renders_persona_templates_in_sandbox(
self,
):
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="criteria {{ stop_signal }}",
description="Test behavior {{ stop_signal }}.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation {{ stop_signal }}"],
)
],
)
prompt = get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=persona,
)
assert "## Criteria: criteria stop" in prompt
assert "Test behavior stop." in prompt
assert " * violation stop" in prompt
def test_get_per_turn_user_simulator_quality_prompt_blocks_unsafe_persona_templates(
self,
):
persona = UserPersona(
id="test_persona",
description="Test persona description.",
behaviors=[
UserBehavior(
name="{{ ''.__class__.__mro__ }}",
description="Test behavior description.",
behavior_instructions=["instruction1"],
violation_rubrics=["violation1"],
)
],
)
with pytest.raises(SecurityError):
get_per_turn_user_simulator_quality_prompt(
conversation_plan="plan",
conversation_history="history",
generated_user_response="response",
stop_signal="stop",
user_persona=persona,
)