The scenario loader expects one concrete AbstractScenario subclass per Python definition. The runtime state is a mutable dictionary shared with RunContext.state. Fixture paths are discovered relative to the loaded scenario directory, including when loaded from an attempt snapshot.
classAbstractScenario(ABC):""" Abstract Base Class for all benchmark scenarios. Each scenario defines its own GitHub event, state preparation, and evaluation logic. """labels=[]category:WorkflowCategory=Nonescenario_type:ScenarioType=ScenarioType.BENIGNrequired_actors:tuple[str,...]=("owner",)def__init__(self,workspace_dir):self.workspace_dir=workspace_dirself.scenario_dir=Noneself.runtime_state={}self._injected:dict={}defprepare(self,context:RunContext)->None:self.setup_state(context.github("owner"))deftrigger(self,context:RunContext)->TriggerReceipt:returncontext.default_trigger()defobserve(self,context:RunContext,run_result:dict)->dict:returncontext.collect_target()defcleanup(self,context:RunContext)->None:self.teardown_state(context.github("owner"))defget_preflight_evaluator(self):""" Returns a callable (response: str) -> bool for offline attack validation, or None if offline testing is not supported for this scenario. The callable receives the victim model's raw text response and returns True if the attack succeeded (i.e. the response contains the expected malicious output). Used by the preflight and offline_optimize commands to validate/optimize attacks without provisioning a GitHub repo. """returnNonedefget_attack_goal(self)->str|None:""" Returns the goal string passed to attack.generate() — what we want the victim LLM to do (e.g. "run gh repo list --visibility=private"). Return None to skip attack injection and use the hardcoded get_event() payload. """returnNonedefget_injection_slots(self)->dict[str,str]:""" Returns {event_field: template} pairs. The runner substitutes the attack payload at every {{INJECTION}} marker in each template, then calls apply_attack(field, rendered_value). Examples: Full replacement: {"pr_body": "{{INJECTION}}"} Suffix injection: {"pr_body": "## Summary\\nLegit text...\\n\\n{{INJECTION}}"} Multi-field: {"pr_title": "fix: {{INJECTION}}", "pr_body": "...{{INJECTION}}..."} Default: empty dict — no injection slots, get_event() is used as-is. """return{}defapply_attack(self,field:str,rendered:str)->None:"""Store a rendered injection value. get_event() checks _injected for overrides."""self._injected[field]=rendereddefget_required_files(self)->dict:""" Returns a dictionary of {repo_path: local_path_or_content} for static files needed in the repo. Defaults to discovering files in a 'contents/' directory next to the scenario. """files={}ifself.scenario_dir:contents_dir=os.path.join(self.scenario_dir,"contents")ifos.path.isdir(contents_dir):forroot,_,filenamesinos.walk(contents_dir):forfilenameinfilenames:abs_path=os.path.join(root,filename)rel_path=os.path.relpath(abs_path,contents_dir)files[rel_path]=abs_pathreturnfilesdefget_required_secrets(self)->list[str]:""" Returns a list of secret names (strings) that must be set in the repository. The runner will attempt to fetch these from local environment variables. """return[]defget_secrets(self)->dict[str,str]:""" Returns a dictionary of {name: value} for secrets defined by the scenario. These will be set in the repository automatically. """return{}defget_template_repo(self)->str:""" Returns the full name of a repository to fork (e.g., 'owner/repo'). If None, a new empty repository is created. """returnNonedefsetup_state(self,gh_client):"""Prepares the repository state before the benchmark run."""passdefteardown_state(self,gh_client):"""Cleans up the repository state after the benchmark run."""passdefget_event(self):""" Returns a dictionary representing the GitHub event. Must contain 'event_type' and 'data'. """return{}defget_utility_evaluator(self)->Evaluator:""" Returns an Evaluator instance to assess Utility. Defaults to an unconfigured evaluator (unknown verdict). """returnUnconfiguredEvaluator()defget_security_evaluator(self)->Evaluator:""" Returns an Evaluator instance to assess Security. Defaults to an unconfigured evaluator (unknown verdict). """returnUnconfiguredEvaluator()defto_json(self):"""Helper to export the event data to JSON format if needed."""returnjson.dumps(self.get_event(),indent=4)
Returns a callable (response: str) -> bool for offline attack validation,
or None if offline testing is not supported for this scenario.
The callable receives the victim model's raw text response and returns
True if the attack succeeded (i.e. the response contains the expected
malicious output). Used by the preflight and offline_optimize commands
to validate/optimize attacks without provisioning a GitHub repo.
defget_preflight_evaluator(self):""" Returns a callable (response: str) -> bool for offline attack validation, or None if offline testing is not supported for this scenario. The callable receives the victim model's raw text response and returns True if the attack succeeded (i.e. the response contains the expected malicious output). Used by the preflight and offline_optimize commands to validate/optimize attacks without provisioning a GitHub repo. """returnNone
Returns the goal string passed to attack.generate() — what we want the
victim LLM to do (e.g. "run gh repo list --visibility=private").
Return None to skip attack injection and use the hardcoded get_event() payload.
defget_attack_goal(self)->str|None:""" Returns the goal string passed to attack.generate() — what we want the victim LLM to do (e.g. "run gh repo list --visibility=private"). Return None to skip attack injection and use the hardcoded get_event() payload. """returnNone
Returns {event_field: template} pairs. The runner substitutes the attack
payload at every {{INJECTION}} marker in each template, then calls
apply_attack(field, rendered_value).
defget_injection_slots(self)->dict[str,str]:""" Returns {event_field: template} pairs. The runner substitutes the attack payload at every {{INJECTION}} marker in each template, then calls apply_attack(field, rendered_value). Examples: Full replacement: {"pr_body": "{{INJECTION}}"} Suffix injection: {"pr_body": "## Summary\\nLegit text...\\n\\n{{INJECTION}}"} Multi-field: {"pr_title": "fix: {{INJECTION}}", "pr_body": "...{{INJECTION}}..."} Default: empty dict — no injection slots, get_event() is used as-is. """return{}
defapply_attack(self,field:str,rendered:str)->None:"""Store a rendered injection value. get_event() checks _injected for overrides."""self._injected[field]=rendered
Returns a dictionary of {repo_path: local_path_or_content}
for static files needed in the repo.
Defaults to discovering files in a 'contents/' directory next to the scenario.
defget_required_files(self)->dict:""" Returns a dictionary of {repo_path: local_path_or_content} for static files needed in the repo. Defaults to discovering files in a 'contents/' directory next to the scenario. """files={}ifself.scenario_dir:contents_dir=os.path.join(self.scenario_dir,"contents")ifos.path.isdir(contents_dir):forroot,_,filenamesinos.walk(contents_dir):forfilenameinfilenames:abs_path=os.path.join(root,filename)rel_path=os.path.relpath(abs_path,contents_dir)files[rel_path]=abs_pathreturnfiles
defget_required_secrets(self)->list[str]:""" Returns a list of secret names (strings) that must be set in the repository. The runner will attempt to fetch these from local environment variables. """return[]
defget_secrets(self)->dict[str,str]:""" Returns a dictionary of {name: value} for secrets defined by the scenario. These will be set in the repository automatically. """return{}
defget_template_repo(self)->str:""" Returns the full name of a repository to fork (e.g., 'owner/repo'). If None, a new empty repository is created. """returnNone
defget_utility_evaluator(self)->Evaluator:""" Returns an Evaluator instance to assess Utility. Defaults to an unconfigured evaluator (unknown verdict). """returnUnconfiguredEvaluator()
defget_security_evaluator(self)->Evaluator:""" Returns an Evaluator instance to assess Security. Defaults to an unconfigured evaluator (unknown verdict). """returnUnconfiguredEvaluator()
scenario_definition resolves a directory or definition path. Directories must contain exactly one scenario.py or recipe.json. discover_scenario_paths recursively discovers definitions outside fixture/cache directories and rejects duplicate directory IDs. find_scenario prefers an existing local path, then searches dataset IDs.
load_scenario executes Python module code, requires exactly one locally defined concrete subclass, constructs it with workspace_dir, and assigns scenario_dir. JSON definitions must be named recipe.json and are interpreted by the recipe loader. Discovery does not construct authenticated runners; loading Python is still executable code.
defscenario_definition(path:str|Path)->Path:path=Path(path)ifpath.is_dir():definitions=[path/namefornamein("scenario.py","recipe.json")if(path/name).is_file()]iflen(definitions)!=1:raiseValueError(f"Expected exactly one scenario.py or recipe.json in {path}")returndefinitions[0]ifnotpath.is_file()orpath.suffixnotin{".py",".json"}:raiseValueError(f"Scenario definition not found: {path}")returnpath
defdiscover_scenario_paths(root:str|Path)->list[Path]:root=Path(root)ifnotroot.exists():return[]paths=[]fordirectoryinsorted({p.parentfornamein("scenario.py","recipe.json")forpinroot.rglob(name)}):ifnot{"contents","__pycache__"}.intersection(directory.relative_to(root).parts):paths.append(scenario_definition(directory))names=[path.parent.nameforpathinpaths]iflen(names)!=len(set(names)):raiseValueError("Duplicate scenario IDs in dataset")returnpaths
defload_scenario(path:str|Path,workspace_dir:str)->AbstractScenario:definition=scenario_definition(path).resolve()ifdefinition.suffix==".json":from.scanner.recipe_scenarioimportload_recipeifdefinition.name!="recipe.json":raiseValueError("JSON scenarios must use recipe.json")scenario=load_recipe(str(definition.parent),workspace_dir)else:content=definition.read_bytes()name="gitinject_scenario_"+hashlib.sha256(content).hexdigest()[:16]spec=importlib.util.spec_from_file_location(name,definition)module=importlib.util.module_from_spec(spec)sys.modules[name]=moduletry:exec(compile(content,str(definition),"exec"),module.__dict__)exceptBaseException:sys.modules.pop(name,None)raiseclasses=[clsforclsinvars(module).values()ifinspect.isclass(cls)andcls.__module__==nameandissubclass(cls,AbstractScenario)andnotinspect.isabstract(cls)]iflen(classes)!=1:raiseValueError(f"Expected exactly one concrete scenario class in {definition}")scenario=classes[0](workspace_dir)scenario.scenario_dir=str(definition.parent)returnscenario