From 84f2b14c5863ea53a29b21fc36a6358502de420d Mon Sep 17 00:00:00 2001 From: VishnuR23 <19866703+VishnuR23@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:37:00 -0700 Subject: [PATCH] FIX: stop seeded converters from reseeding the global RNG ZalgoConverter, ProportionSelectionStrategy and WordProportionSelectionStrategy called random.seed() on the process-wide RNG. Passing seed= to any one of them reset global random state on every conversion, so every other component drawing from the `random` module (~17 modules, including CharSwapConverter, RandomCapitalLettersConverter, InsertPunctuationConverter and seed sampling) silently stopped varying. Each of the three now owns a random.Random instance instead. Seeded output is byte-identical to before; only the global side effect is removed. Co-Authored-By: Claude Opus 5 (1M context) --- pyrit/converter/text_selection_strategy.py | 14 +++++++--- pyrit/converter/zalgo_converter.py | 12 ++++++-- .../test_char_swap_generator_converter.py | 19 +++++++------ .../converter/test_text_selection_strategy.py | 21 ++++++++++++++ tests/unit/converter/test_zalgo_converter.py | 28 +++++++++++++++++++ 5 files changed, 79 insertions(+), 15 deletions(-) diff --git a/pyrit/converter/text_selection_strategy.py b/pyrit/converter/text_selection_strategy.py index cdfd6abedb..6a0e6c66f4 100644 --- a/pyrit/converter/text_selection_strategy.py +++ b/pyrit/converter/text_selection_strategy.py @@ -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]: """ @@ -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) @@ -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]: """ @@ -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): diff --git a/pyrit/converter/zalgo_converter.py b/pyrit/converter/zalgo_converter.py index 29c9c70ee2..7a998fd7ed 100644 --- a/pyrit/converter/zalgo_converter.py +++ b/pyrit/converter/zalgo_converter.py @@ -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: """ @@ -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) diff --git a/tests/unit/converter/test_char_swap_generator_converter.py b/tests/unit/converter/test_char_swap_generator_converter.py index ad23e6154a..de96689b31 100644 --- a/tests/unit/converter/test_char_swap_generator_converter.py +++ b/tests/unit/converter/test_char_swap_generator_converter.py @@ -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) diff --git a/tests/unit/converter/test_text_selection_strategy.py b/tests/unit/converter/test_text_selection_strategy.py index ef90e2d2d9..0064be228e 100644 --- a/tests/unit/converter/test_text_selection_strategy.py +++ b/tests/unit/converter/test_text_selection_strategy.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import random + import pytest from pyrit.converter.text_selection_strategy import ( @@ -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) @@ -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"] diff --git a/tests/unit/converter/test_zalgo_converter.py b/tests/unit/converter/test_zalgo_converter.py index 4295fbe043..dc4875305d 100644 --- a/tests/unit/converter/test_zalgo_converter.py +++ b/tests/unit/converter/test_zalgo_converter.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import random + import pytest from pyrit.converter import ZalgoConverter @@ -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) + 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)