Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
14 changes: 10 additions & 4 deletions pyrit/converter/text_selection_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ def __init__(self, *, proportion: float, anchor: str = "start", seed: int | None
self._proportion = proportion
self._anchor = anchor
self._seed = seed
# Own the RNG rather than seeding the global one, so a seeded strategy
# does not make every other `random`-based component reproducible.
self._rng = random.Random(seed)

def select_range(self, *, text: str) -> tuple[int, int]:
"""
Expand All @@ -338,9 +341,9 @@ def select_range(self, *, text: str) -> tuple[int, int]:
return (start, start + selection_len)
# random
if self._seed is not None:
random.seed(self._seed)
self._rng.seed(self._seed)
max_start = max(0, text_len - selection_len)
start = random.randint(0, max_start) if max_start > 0 else 0
start = self._rng.randint(0, max_start) if max_start > 0 else 0
return (start, start + selection_len)


Expand Down Expand Up @@ -488,6 +491,9 @@ def __init__(self, *, proportion: float, seed: int | None = None) -> None:

self._proportion = proportion
self._seed = seed
# Own the RNG rather than seeding the global one, so a seeded strategy
# does not make every other `random`-based component reproducible.
self._rng = random.Random(seed)

def select_words(self, *, words: list[str]) -> list[int]:
"""
Expand All @@ -503,10 +509,10 @@ def select_words(self, *, words: list[str]) -> list[int]:
return []

if self._seed is not None:
random.seed(self._seed)
self._rng.seed(self._seed)

num_to_select = int(len(words) * self._proportion)
return random.sample(range(len(words)), num_to_select) if num_to_select > 0 else []
return self._rng.sample(range(len(words)), num_to_select) if num_to_select > 0 else []


class WordRegexSelectionStrategy(WordSelectionStrategy):
Expand Down
12 changes: 9 additions & 3 deletions pyrit/converter/zalgo_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ def __init__(
super().__init__(word_selection_strategy=word_selection_strategy)
self._intensity = self._normalize_intensity(intensity)
self._seed = seed
# Own the RNG rather than seeding the global one, so a seeded converter
# does not make every other `random`-based component reproducible.
self._rng = random.Random(seed)

def _build_identifier(self) -> ComponentIdentifier:
"""
Expand Down Expand Up @@ -83,12 +86,15 @@ async def convert_word_async(self, word: str) -> str:
return word

def glitch(char: str) -> str:
return char + "".join(random.choice(self.ZALGO_MARKS) for _ in range(random.randint(1, self._intensity)))
return char + "".join(
self._rng.choice(self.ZALGO_MARKS) for _ in range(self._rng.randint(1, self._intensity))
)

return "".join(glitch(c) if c.isalnum() else c for c in word)

def validate_input(self, prompt: str) -> None:
"""Validate the input prompt before conversion."""
# Initialize the random seed before processing any words
# Reset the converter's own RNG before processing any words, so a seeded
# converter yields the same output on every call.
if self._seed is not None:
random.seed(self._seed)
self._rng.seed(self._seed)

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.

This no longer guarantees the converter's documented reproducibility when Zalgo is composed with an unseeded random word selector. WordLevelConverter.convert_async() performs word selection after this reset, but WordProportionSelectionStrategy now owns an independent RNG, so repeated calls to ZalgoConverter(seed=42, word_selection_strategy=WordProportionSelectionStrategy(proportion=0.5)) can select different words and produce different outputs. Please either use one operation-local RNG across selection and mark generation, or explicitly define seeds as component-scoped and update the public contract accordingly. Add a regression test for this composed case.

19 changes: 11 additions & 8 deletions tests/unit/converter/test_char_swap_generator_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,17 @@ async def test_char_swap_converter_proportion_unchanged_with_iterations():
prompt = "Testing multiple words here today"

# 50% proportion should select ~2-3 of the 5 eligible words, regardless of max_iterations
converter = CharSwapConverter(
max_iterations=10,
word_selection_strategy=WordProportionSelectionStrategy(proportion=0.5),
)

# Mock random.sample to select exactly 2 words (indices 0 and 2)
# This simulates the word selection strategy picking "Testing" and "words"
with patch("random.sample", return_value=[0, 2]) as mock_sample, patch("random.randint", return_value=1):
strategy = WordProportionSelectionStrategy(proportion=0.5)
converter = CharSwapConverter(max_iterations=10, word_selection_strategy=strategy)

# Mock the strategy's own RNG to select exactly 2 words (indices 0 and 2).
# This simulates the word selection strategy picking "Testing" and "words".
# The strategy draws from a private Random instance rather than the global
# `random` module, so that a seeded strategy cannot disturb process-wide state.
with (
patch.object(strategy._rng, "sample", return_value=[0, 2]) as mock_sample,
patch("random.randint", return_value=1),
):
result = await converter.convert_async(prompt=prompt)

# Verify sample was called once (word selection happens once, not per iteration)
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/converter/test_text_selection_strategy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import random

import pytest

from pyrit.converter.text_selection_strategy import (
Expand Down Expand Up @@ -219,6 +221,15 @@ def test_select_range_random_anchor_with_seed(self):
result2 = strategy2.select_range(text="0123456789")
assert result1 == result2 # Same seed should give same result

def test_select_range_seed_does_not_disturb_global_rng(self):
"""A seeded strategy must not reseed the process-wide RNG."""
random.seed(0)
state_before = random.getstate()

ProportionSelectionStrategy(proportion=0.3, anchor="random", seed=42).select_range(text="0123456789")

assert random.getstate() == state_before

def test_select_range_random_anchor_different_seeds(self):
strategy1 = ProportionSelectionStrategy(proportion=0.3, anchor="random", seed=42)
strategy2 = ProportionSelectionStrategy(proportion=0.3, anchor="random", seed=43)
Expand Down Expand Up @@ -383,6 +394,16 @@ def test_select_words_reproducible_with_seed(self):
result2 = strategy2.select_words(words=words)
assert result1 == result2

def test_select_words_seed_does_not_disturb_global_rng(self):
"""A seeded strategy must not reseed the process-wide RNG."""
random.seed(0)
state_before = random.getstate()

strategy = WordProportionSelectionStrategy(proportion=0.3, seed=42)
strategy.select_words(words=["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"])

assert random.getstate() == state_before

def test_select_words_zero_proportion(self):
strategy = WordProportionSelectionStrategy(proportion=0.0, seed=42)
words = ["a", "b", "c", "d", "e"]
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/converter/test_zalgo_converter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

import random

import pytest

from pyrit.converter import ZalgoConverter
Expand All @@ -23,6 +25,32 @@ async def test_zalgo_reproducible_seed():
assert result1.output_text == result2.output_text


async def test_zalgo_seed_does_not_disturb_global_rng():
"""A seeded converter must not reseed the process-wide RNG."""
random.seed(0)

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.

This regression test mutates the process-wide RNG and leaves it seeded at 0, which can make later tests order-dependent. The same pattern appears in both new selection-strategy tests. No setup seed is needed here: capture the existing random.getstate(), exercise the component, and compare against that state. Alternatively, restore the original state in a finally block.

state_before = random.getstate()

converter = ZalgoConverter(intensity=5, seed=42)
await converter.convert_async(prompt="seed test")

assert random.getstate() == state_before


async def test_zalgo_seed_is_repeatable_on_same_instance():
prompt = "seed test"
converter = ZalgoConverter(intensity=5, seed=123)
first = await converter.convert_async(prompt=prompt)
second = await converter.convert_async(prompt=prompt)
assert first.output_text == second.output_text


async def test_zalgo_unseeded_converters_stay_independent():
"""An unseeded converter must keep producing varied output."""
converter = ZalgoConverter(intensity=5)
outputs = {(await converter.convert_async(prompt="seed test")).output_text for _ in range(5)}
assert len(outputs) > 1


async def test_zalgo_zero_intensity_returns_original():
prompt = "no chaos please"
converter = ZalgoConverter(intensity=0)
Expand Down