Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 56 additions & 9 deletions pyrit/models/seeds/seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

import functools
import logging
import re
import uuid
Expand All @@ -23,7 +24,7 @@
from pyrit.models.seeds.seed_origin import SeedOrigin

if TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from pathlib import Path

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -81,25 +82,66 @@ def __repr__(self) -> str:
"""
return f"{{{{ {self._undefined_name} }}}}" if self._undefined_name else ""

# A placeholder cannot decide a branch or a loop: answering now would drop the
# {% if %} or {% for %} tags, and the later render could not decide again.
def __iter__(self) -> Iterator[object]:
"""
Return an empty iterator to prevent iteration over undefined variables.
Defer rendering instead of iterating over an unresolved variable.

Returns:
Iterator[object]: Empty iterator.
Raises:
_DeferRenderError: Always.

"""
return iter([])
raise _DeferRenderError(self._undefined_name)

def __bool__(self) -> bool:
"""
Evaluate as truthy to avoid falsey-branch side effects.
Defer rendering instead of testing an unresolved variable.

Returns:
bool: Always True.
Raises:
_DeferRenderError: Always.

"""
raise _DeferRenderError(self._undefined_name)

def __eq__(self, other: object) -> bool:
"""
return True # Ensures it doesn't evaluate to False
Defer rendering instead of comparing an unresolved variable.

Raises:
_DeferRenderError: Always.

"""
raise _DeferRenderError(self._undefined_name)

def __ne__(self, other: object) -> bool:
"""
Defer rendering instead of comparing an unresolved variable.

Raises:
_DeferRenderError: Always.

"""
raise _DeferRenderError(self._undefined_name)

__hash__ = Undefined.__hash__


class _DeferRenderError(Exception):
"""Raised when an unresolved variable would decide a branch or a loop."""


def _deferring(function: Callable[..., Any]) -> Callable[..., Any]:
# Jinja tests such as `is defined` and the `default` filter check the value's type, not its truth.
# functools.wraps keeps Jinja's pass_environment marker, so the value may not be the first argument.
@functools.wraps(function)
def deferring(*args: Any, **kwargs: Any) -> Any:
for arg in args:
if isinstance(arg, PartialUndefined):
raise _DeferRenderError(arg._undefined_name)
return function(*args, **kwargs)

return deferring


class Seed(BaseModel):
Expand Down Expand Up @@ -223,11 +265,16 @@ def render_template_value_silent(self, **kwargs: Any) -> str:

# Create a Jinja template with PartialUndefined placeholders
env = SandboxedEnvironment(undefined=PartialUndefined)
env.tests = {name: _deferring(test) for name, test in env.tests.items()}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must Fix: This assignment fails the required type check with the locked dependencies:

uv run --frozen --no-sync ty check pyrit\models\seeds\seed.py
error[invalid-assignment] at seed.py:268

_deferring returns Callable[..., Any], which does not preserve the signatures of the functions in Jinja's inferred env.tests dictionary. The new dictionary is therefore not assignable to env.tests. The repository's ty-check pre-commit hook checks all of pyrit, so this blocks that gate even though the runtime tests pass.

Please preserve the wrapped callable's argument and return types, then rerun the type-check hook with the locked environment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e97c4a0. _deferring is now typed (function: T) -> T with T bound to Callable[..., Any], so each wrapped test and the default filter keep their own type and env.tests accepts the new table. With the locked ty 0.0.84, ty check pyrit/models/seeds passes, and ty check pyrit reports the same diagnostics as main here (only imports of optional extras I do not have installed).

env.filters["default"] = env.filters["d"] = _deferring(env.filters["default"])
is_jinja_template = env.from_string(self.value)

try:
# Render the template with the provided kwargs
return is_jinja_template.render(**kwargs)
except _DeferRenderError:
# A missing parameter decides a branch or a loop - preserve the template as-is
return self.value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must Fix: Returning the original template here loses values supplied in earlier rendering passes. This breaks the existing TextJailBreak API:

from pyrit.datasets import TextJailBreak

template = TextJailBreak(
    string_template="Style: {{ style }}. {% if prompt %}{{ prompt }}{% endif %}",
    style="brief",
)
template.get_jailbreak("Explain rainbows")

This returns Style: brief. Explain rainbows on both current main and v1.1.0, but raises 'style' is undefined with this change. _apply_extra_kwargs supplies style once, and get_jailbreak supplies only prompt. A trusted SeedPrompt combining {{ datasets_path }} with an unresolved guard also loses the path resolved during construction and fails on its final render.

Please preserve the already supplied values while deferring the unresolved condition, or retain and forward the bound context throughout these callers. Add regression tests for the construction/partial/final rendering chain, not just a final render that supplies everything again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, thanks. In e97c4a0 the deferral applies only to the load-time render of trusted templates (SeedPrompt and SeedObjective construction), through a separate placeholder class. render_template_value_silent is back to the behavior on main, so TextJailBreak extra kwargs and the adversarial first message render as before: your example returns Style: brief. Explain rainbows again.

When the load render defers, each parameter it was given (the dataset paths) that the template uses is written in front of the template as a {% set %}. So {{ datasets_path }} next to an unresolved guard still resolves at the final render, also after memory rebuilds the prompt from its stored value. No shipped template gets such a line: loading all YAML and .prompt files still differs from main only in the nine guarded Crescendo and TAP templates.

New tests for the chains:

  • test_get_jailbreak_keeps_extra_kwargs_of_a_template_with_a_prompt_guard: your example, construction with style and then get_jailbreak.
  • test_seed_prompt_keeps_path_resolved_at_load_when_its_condition_is_deferred: construction, then the final render with and without context, on the original and on a copy rebuilt from its value.
  • test_render_template_value_silent_decides_conditions_as_before, and test_render_template_value_silent_renders_condition_once_its_parameters_are_provided now checks the path bound at load.

These cases fail on df3331e and pass now. I also ran every shipped jailbreak template (650) through construction and get_jailbreak on main and on this branch, and the output is identical.

except Exception as e:
logger.error("Error rendering template: %s", e)
return self.value
Expand Down
1 change: 1 addition & 0 deletions tests/unit/executor/attack/multi_turn/test_crescendo.py
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ async def test_setup_sets_adversarial_chat_system_prompt(
call_args = mock_adversarial_chat.set_system_prompt_async.call_args
assert "Test objective" in call_args.kwargs["system_prompt"]
assert "15" in call_args.kwargs["system_prompt"] # Check for the max_turns value
assert "Prior Conversation Context" not in call_args.kwargs["system_prompt"]
assert call_args.kwargs["conversation_id"] == basic_context.session.adversarial_chat_conversation_id

async def test_setup_handles_prepended_conversation_with_refusal(
Expand Down
73 changes: 73 additions & 0 deletions tests/unit/models/test_seed.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@

import numpy as np
import pytest
import yaml
from jinja2 import StrictUndefined
from jinja2.sandbox import SandboxedEnvironment
from PIL import Image
from scipy.io import wavfile

Expand Down Expand Up @@ -204,6 +207,76 @@ def test_render_template_value_silent_blocks_ssti_via_endraw_injection():
assert "__class__" not in result or result == raw_wrapped


@pytest.mark.parametrize(
("template_value", "parameters", "expected"),
[
("{% if flag %}A{% else %}B{% endif %}", {"flag": True}, "A"),
("{% if flag %}A{% else %}B{% endif %}", {"flag": False}, "B"),
("{% if flag %}A{% else %}B{% endif %}", {"flag": None}, "B"),
("{% if other %}A{% elif flag %}B{% else %}C{% endif %}", {"other": False, "flag": False}, "C"),
('{{ "A" if flag else "B" }}', {"flag": False}, "B"),
("{% set local = flag %}{% if local %}A{% else %}B{% endif %}", {"flag": False}, "B"),
("{% if flag is defined %}A{% else %}B{% endif %}", {}, "B"),
("{{ flag | default('B') }}", {}, "B"),
("{% if flag == 'a' %}A{% else %}B{% endif %}", {"flag": "b"}, "B"),
("{% if flag is filter %}A{% else %}B{% endif %}", {"flag": "upper"}, "A"),
("{% if flag is test %}A{% else %}B{% endif %}", {"flag": "nope"}, "B"),
("{% for item in ['A', 'B'] if flag %}{{ item }}{% endfor %}", {"flag": False}, ""),
(
"{% macro show(rows) %}{% for row in rows %}[{{ row }}]{% endfor %}{% endmacro %}{{ show(items) }}",
{"items": ["x", "y"]},
"[x][y]",
),
],
)
def test_seed_prompt_keeps_template_whose_missing_parameter_decides_a_branch(template_value, parameters, expected):
template = SeedPrompt(value=template_value, data_type="text", is_jinja_template=True)

assert template.value == template_value
assert template.render_template_value(**parameters) == expected


def test_render_template_value_silent_renders_condition_once_its_parameters_are_provided():
template = SeedPrompt(
value="{% if flag %}{{ datasets_path }} {{ prompt }}{% endif %}",
data_type="text",
is_jinja_template=True,
)

assert template.render_template_value_silent(flag=True, datasets_path="data") == "data {{ prompt }}"


def test_render_template_value_silent_renders_if_guard_on_loop_variable():
seed = SeedPrompt(
value="{% for item in items %}{% if item %}[{{ item }}]{% endif %}{% endfor %}",
data_type="text",
is_jinja_template=True,
)

assert seed.render_template_value_silent(items=["a", "", "b"]) == "[a][b]"


_CONVERSATION_CONTEXT_TEMPLATES = sorted(
path
for path in pathlib.Path(DATASETS_PATH, "executors").rglob("*.yaml")
if "{% if conversation_context %}" in path.read_text(encoding="utf-8")
)


@pytest.mark.parametrize("template_path", _CONVERSATION_CONTEXT_TEMPLATES, ids=lambda path: path.stem)
@pytest.mark.parametrize("conversation_context", [None, "<context>"])
def test_loaded_template_renders_like_its_source(template_path, conversation_context):
seed_prompt = SeedPrompt.from_yaml_file(template_path)
parameters = {name: f"<{name}>" for name in seed_prompt.parameters or []}
parameters["conversation_context"] = conversation_context
source = yaml.safe_load(template_path.read_text(encoding="utf-8"))["value"]

expected = SandboxedEnvironment(undefined=StrictUndefined).from_string(source).render(**parameters)

assert seed_prompt.render_template_value(**parameters) == expected
assert ("<context>" in expected) == (conversation_context is not None)


def test_seed_group_untrusted_auto_escapes():
group = SeedGroup(seeds=[{"value": '{{ "".__class__ }}', "data_type": "text"}])
seed = group.prompts[0]
Expand Down
Loading