Skip to content

Commit d45880c

Browse files
authored
FIX: List Scenarios Bug (microsoft#1216)
1 parent e2760a0 commit d45880c

3 files changed

Lines changed: 94 additions & 3 deletions

File tree

doc/code/front_end/2_pyrit_shell.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# PyRIT Shell - Interactive Command Line
1+
# 2. PyRIT Shell - Interactive Command Line
22

33
PyRIT Shell provides an interactive REPL (Read-Eval-Print Loop) for running AI red teaming scenarios with fast execution and session-based result tracking.
44

pyrit/cli/scenario_registry.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,12 @@ def discover_modules(base_path: Path, base_module: str) -> None:
9494
if module_name.startswith("_"):
9595
continue
9696

97-
full_module_name = f"pyrit.scenario.scenarios.{base_module}.{module_name}"
97+
# Build the full module name correctly
98+
if base_module:
99+
full_module_name = f"{base_module}.{module_name}"
100+
else:
101+
full_module_name = f"pyrit.scenario.scenarios.{module_name}"
102+
98103
try:
99104
# Import the module
100105
module = importlib.import_module(full_module_name)
@@ -119,7 +124,7 @@ def discover_modules(base_path: Path, base_module: str) -> None:
119124
logger.warning(f"Failed to load scenario module {full_module_name}: {e}")
120125

121126
# Start discovery from the scenarios package root
122-
discover_modules(package_path, "pyrit.scenarios.scenarios")
127+
discover_modules(package_path, "")
123128

124129
except Exception as e:
125130
logger.error(f"Failed to discover built-in scenarios: {e}")

tests/unit/cli/test_scenario_registry.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,92 @@ def test_discover_builtin_scenarios(self):
5959
assert scenario_class is not None
6060
assert issubclass(scenario_class, Scenario)
6161

62+
def test_discover_builtin_scenarios_correct_module_paths(self):
63+
"""Test that builtin scenario discovery uses correct module paths without duplication.
64+
65+
This is a regression test for a bug where module paths were incorrectly constructed
66+
as 'pyrit.scenario.scenarios.pyrit.scenarios.scenarios.xxx' instead of
67+
'pyrit.scenario.scenarios.xxx'.
68+
"""
69+
with patch("pyrit.cli.scenario_registry.importlib.import_module") as mock_import:
70+
with patch("pyrit.cli.scenario_registry.pkgutil.iter_modules") as mock_iter:
71+
with patch("pyrit.cli.scenario_registry.inspect.getmembers") as mock_getmembers:
72+
# Mock the scenarios package structure
73+
def iter_modules_side_effect(paths):
74+
path_str = str(paths[0]) if paths else ""
75+
if "airt" in path_str:
76+
# Mock the airt subpackage
77+
return [
78+
(None, "content_harms_scenario", False),
79+
(None, "cyber_scenario", False),
80+
]
81+
else:
82+
# Mock the main scenarios package
83+
return [
84+
(None, "encoding_scenario", False), # A file module
85+
(None, "foundry_scenario", False), # Another file module
86+
(None, "airt", True), # A package (subdirectory)
87+
]
88+
89+
mock_iter.side_effect = iter_modules_side_effect
90+
91+
# Create mock module with Scenario subclass
92+
mock_scenario_class = type(
93+
"TestScenario",
94+
(Scenario,),
95+
{
96+
"_get_atomic_attacks_async": lambda self: [],
97+
"get_strategy_class": classmethod(lambda cls: MockStrategy),
98+
"get_default_strategy": classmethod(lambda cls: MockStrategy.ALL),
99+
},
100+
)
101+
102+
# Mock getmembers to return our test scenario
103+
mock_getmembers.return_value = [("TestScenario", mock_scenario_class)]
104+
105+
# Mock import_module to return a simple module object
106+
def import_side_effect(module_name):
107+
mock_mod = MagicMock()
108+
mock_mod.__name__ = module_name
109+
return mock_mod
110+
111+
mock_import.side_effect = import_side_effect
112+
113+
registry = ScenarioRegistry()
114+
registry._discover_builtin_scenarios()
115+
116+
# Verify the correct module paths were attempted
117+
import_calls = [call[0][0] for call in mock_import.call_args_list]
118+
119+
# Filter to only scenario module imports (not the base package imports)
120+
# We expect imports like:
121+
# - pyrit.scenario.scenarios.encoding_scenario
122+
# - pyrit.scenario.scenarios.foundry_scenario
123+
# - pyrit.scenario.scenarios.airt.content_harms_scenario
124+
# - pyrit.scenario.scenarios.airt.cyber_scenario
125+
scenario_module_imports = [
126+
call
127+
for call in import_calls
128+
if call.startswith("pyrit.scenario.scenarios.") and call != "pyrit.scenario.scenarios"
129+
]
130+
131+
# Should NOT see duplicated paths like:
132+
# - pyrit.scenario.scenarios.pyrit.scenarios.scenarios.xxx
133+
134+
assert len(scenario_module_imports) > 0, "No scenario modules were imported"
135+
136+
for call_path in scenario_module_imports:
137+
# Verify no path duplication
138+
assert (
139+
"pyrit.scenario.scenarios.pyrit" not in call_path
140+
), f"Module path has duplication: {call_path}"
141+
# Verify 'scenarios' appears exactly once (not duplicated)
142+
assert call_path.count("scenarios") == 1, f"Module path has 'scenarios' duplicated: {call_path}"
143+
# Verify correct base path
144+
assert call_path.startswith(
145+
"pyrit.scenario.scenarios."
146+
), f"Module path doesn't start with correct base: {call_path}"
147+
62148
def test_get_scenario_existing(self):
63149
"""Test getting an existing scenario."""
64150
registry = ScenarioRegistry()

0 commit comments

Comments
 (0)