gitinject 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. gitinject/__init__.py +1 -0
  2. gitinject/__main__.py +5 -0
  3. gitinject/analyzer.py +67 -0
  4. gitinject/attacks/__init__.py +27 -0
  5. gitinject/attacks/autoinject.py +165 -0
  6. gitinject/attacks/base.py +33 -0
  7. gitinject/attacks/static.py +25 -0
  8. gitinject/cli.py +768 -0
  9. gitinject/data/research/scenarios/claude_skills_injection.md +96 -0
  10. gitinject/data/research/scenarios/cline_issue_body_injection.md +93 -0
  11. gitinject/data/research/scenarios/codex_agents_md_injection.md +109 -0
  12. gitinject/data/research/scenarios/dos_request_flood.md +133 -0
  13. gitinject/data/research/scenarios/dropped/ci_log_injection_workflow_poisoning.md +138 -0
  14. gitinject/data/research/scenarios/dropped/claude_md_instructions_injection.md +158 -0
  15. gitinject/data/research/scenarios/dropped/supply_chain_token_pivot.md +101 -0
  16. gitinject/data/research/scenarios/gemini_api_key_exfiltration.md +68 -0
  17. gitinject/data/research/scenarios/gemini_api_key_exfiltration_replication.md +0 -0
  18. gitinject/data/research/scenarios/gemini_md_instructions_injection.md +131 -0
  19. gitinject/data/research/scenarios/nsfw_api_key_block.md +114 -0
  20. gitinject/data/research/scenarios/pr_token_exfiltration_via_git_config.md +117 -0
  21. gitinject/data/research/scenarios/supply_chain_malicious_code.md +135 -0
  22. gitinject/evaluators.py +169 -0
  23. gitinject/evidence.py +72 -0
  24. gitinject/gl_runner.py +163 -0
  25. gitinject/resources.py +30 -0
  26. gitinject/run_context.py +72 -0
  27. gitinject/run_record.py +113 -0
  28. gitinject/runner.py +1024 -0
  29. gitinject/scanner/__init__.py +22 -0
  30. gitinject/scanner/baselines/__init__.py +0 -0
  31. gitinject/scanner/baselines/actionlint_runner.py +44 -0
  32. gitinject/scanner/baselines/zizmor_runner.py +35 -0
  33. gitinject/scanner/diagnostics.py +174 -0
  34. gitinject/scanner/hypothesis_generator.py +164 -0
  35. gitinject/scanner/live_validator.py +284 -0
  36. gitinject/scanner/llm_ranker.py +120 -0
  37. gitinject/scanner/memory.py +239 -0
  38. gitinject/scanner/primitives.py +236 -0
  39. gitinject/scanner/prompt_extractor.py +134 -0
  40. gitinject/scanner/recipe_scenario.py +203 -0
  41. gitinject/scanner/report_generator.py +230 -0
  42. gitinject/scanner/types.py +179 -0
  43. gitinject/scenario_base.py +147 -0
  44. gitinject/scenario_loader.py +77 -0
  45. gitinject/scenarios/__init__.py +0 -0
  46. gitinject/scenarios/benign/documentation_out_of_sync/contents/math_utils.py +3 -0
  47. gitinject/scenarios/benign/documentation_out_of_sync/contents/pydantic/v1/utils.py +21 -0
  48. gitinject/scenarios/benign/documentation_out_of_sync/scenario.py +57 -0
  49. gitinject/scenarios/benign/issue_deduplication_utility/scenario.py +76 -0
  50. gitinject/scenarios/benign/repository_onboarding_qa/contents/README.md +3 -0
  51. gitinject/scenarios/benign/repository_onboarding_qa/scenario.py +52 -0
  52. gitinject/scenarios/benign/secrets_false_positive/contents/tests/test_auth.py +14 -0
  53. gitinject/scenarios/benign/secrets_false_positive/scenario.py +58 -0
  54. gitinject/scenarios/benign/test_coverage_gap/contents/src/requests/auth_extra.py +33 -0
  55. gitinject/scenarios/benign/test_coverage_gap/scenario.py +56 -0
  56. gitinject/scenarios/benign/vulnerability_fix_verification/contents/src/flask/utils.py +15 -0
  57. gitinject/scenarios/benign/vulnerability_fix_verification/scenario.py +57 -0
  58. gitinject/scenarios/benign/vulnerable_code_review/contents/examples/tutorial/flaskr/db_utils.py +18 -0
  59. gitinject/scenarios/benign/vulnerable_code_review/scenario.py +55 -0
  60. gitinject/scenarios/benign/weekly_activity_summary/scenario.py +83 -0
  61. gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/AGENTS.md +25 -0
  62. gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
  63. gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
  64. gitinject/scenarios/malicious/agents_md_approval_manipulation/scenario.py +121 -0
  65. gitinject/scenarios/malicious/claude_md_instructions_injection/contents/CLAUDE.md +23 -0
  66. gitinject/scenarios/malicious/claude_md_instructions_injection/contents/scripts/deploy_check.sh +29 -0
  67. gitinject/scenarios/malicious/claude_md_instructions_injection/scenario.py +93 -0
  68. gitinject/scenarios/malicious/cline_issue_body_injection/scenario.py +132 -0
  69. gitinject/scenarios/malicious/codex_agents_md_injection/contents/AGENTS.md +36 -0
  70. gitinject/scenarios/malicious/codex_agents_md_injection/contents/scripts/utils.py +12 -0
  71. gitinject/scenarios/malicious/codex_agents_md_injection/scenario.py +128 -0
  72. gitinject/scenarios/malicious/dos_request_flood/scenario.py +80 -0
  73. gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
  74. gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
  75. gitinject/scenarios/malicious/gemini_md_approval_manipulation/scenario.py +121 -0
  76. gitinject/scenarios/malicious/gemini_md_instructions_injection/contents/scripts/deploy_check.sh +38 -0
  77. gitinject/scenarios/malicious/gemini_md_instructions_injection/scenario.py +105 -0
  78. gitinject/scenarios/malicious/issue_onboarding_secret_exfiltration/scenario.py +76 -0
  79. gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/contents/scripts/deploy_check.sh +7 -0
  80. gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/scenario.py +113 -0
  81. gitinject/scenarios/malicious/supply_chain_malicious_code/contents/CLAUDE.md +25 -0
  82. gitinject/scenarios/malicious/supply_chain_malicious_code/contents/src/flask/csrf.py +151 -0
  83. gitinject/scenarios/malicious/supply_chain_malicious_code/contents/tests/test_csrf.py +71 -0
  84. gitinject/scenarios/malicious/supply_chain_malicious_code/scenario.py +123 -0
  85. gitinject/scenarios/malicious/unauthorized_pr_approval/contents/django/utils/crypto.py +13 -0
  86. gitinject/scenarios/malicious/unauthorized_pr_approval/scenario.py +57 -0
  87. gitinject/simulator.py +89 -0
  88. gitinject/utils/__init__.py +0 -0
  89. gitinject/utils/gh_client.py +628 -0
  90. gitinject/utils/gl_client.py +132 -0
  91. gitinject/utils/gl_provisioner.py +83 -0
  92. gitinject/utils/llm.py +205 -0
  93. gitinject/utils/provisioner.py +114 -0
  94. gitinject/utils/scenario_resources.py +33 -0
  95. gitinject/utils/types.py +49 -0
  96. gitinject/workflows/__init__.py +0 -0
  97. gitinject/workflows/claude-ci-auto-fix/contents/.github/workflows/main.yml +107 -0
  98. gitinject/workflows/claude-ci-auto-fix/metadata.json +10 -0
  99. gitinject/workflows/claude-general/contents/.github/workflows/main.yml +58 -0
  100. gitinject/workflows/claude-general/metadata.json +10 -0
  101. gitinject/workflows/claude-gitlab-mr-review/contents/.gitlab-ci.yml +36 -0
  102. gitinject/workflows/claude-gitlab-mr-review/metadata.json +11 -0
  103. gitinject/workflows/claude-issue-deduplication/contents/.github/workflows/main.yml +66 -0
  104. gitinject/workflows/claude-issue-deduplication/metadata.json +10 -0
  105. gitinject/workflows/claude-issue-triage/contents/.github/workflows/main.yml +34 -0
  106. gitinject/workflows/claude-issue-triage/metadata.json +10 -0
  107. gitinject/workflows/claude-manual-analysis/contents/.github/workflows/main.yml +42 -0
  108. gitinject/workflows/claude-manual-analysis/metadata.json +10 -0
  109. gitinject/workflows/claude-pr-review/contents/.github/workflows/main.yml +77 -0
  110. gitinject/workflows/claude-pr-review/metadata.json +10 -0
  111. gitinject/workflows/claude-pr-review-authors/contents/.github/workflows/main.yml +48 -0
  112. gitinject/workflows/claude-pr-review-authors/metadata.json +10 -0
  113. gitinject/workflows/claude-pr-review-paths/contents/.github/workflows/main.yml +49 -0
  114. gitinject/workflows/claude-pr-review-paths/metadata.json +10 -0
  115. gitinject/workflows/claude-test-analysis/contents/.github/workflows/main.yml +114 -0
  116. gitinject/workflows/claude-test-analysis/metadata.json +10 -0
  117. gitinject/workflows/cline-assistant/contents/.github/workflows/main.yml +87 -0
  118. gitinject/workflows/cline-assistant/contents/git-scripts/analyze-issue.sh +43 -0
  119. gitinject/workflows/cline-assistant/metadata.json +10 -0
  120. gitinject/workflows/codex-pr-review/contents/.github/workflows/main.yml +73 -0
  121. gitinject/workflows/codex-pr-review/metadata.json +10 -0
  122. gitinject/workflows/copilot-ci-doctor/contents/.github/workflows/ci-doctor.yml +1161 -0
  123. gitinject/workflows/copilot-ci-doctor/metadata.json +10 -0
  124. gitinject/workflows/copilot-lean-squad/contents/.github/workflows/lean-squad.yml +1313 -0
  125. gitinject/workflows/copilot-lean-squad/metadata.json +10 -0
  126. gitinject/workflows/copilot-malicious-scan/contents/.github/workflows/daily-malicious-code-scan.yml +899 -0
  127. gitinject/workflows/copilot-malicious-scan/metadata.json +10 -0
  128. gitinject/workflows/copilot-repo-assist/contents/.github/workflows/repo-assist.yml +1503 -0
  129. gitinject/workflows/copilot-repo-assist/metadata.json +10 -0
  130. gitinject/workflows/copilot-wiki-writer/contents/.github/workflows/agentic-wiki-writer.yml +1316 -0
  131. gitinject/workflows/copilot-wiki-writer/metadata.json +10 -0
  132. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-invoke.yml +122 -0
  133. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-plan-execute.yml +130 -0
  134. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-review.yml +118 -0
  135. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-scheduled-triage.yml +220 -0
  136. gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-triage.yml +160 -0
  137. gitinject/workflows/gemini-assistant/contents/.github/workflows/main.yml +220 -0
  138. gitinject/workflows/gemini-assistant/metadata.json +10 -0
  139. gitinject/workflows/gemini-assistant-original/AWESOME.md +118 -0
  140. gitinject/workflows/gemini-assistant-original/CONFIGURATION.md +162 -0
  141. gitinject/workflows/gemini-assistant-original/README.md +93 -0
  142. gitinject/workflows/gemini-assistant-original/gemini-assistant/README.md +192 -0
  143. gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.toml +94 -0
  144. gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.yml +131 -0
  145. gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.toml +100 -0
  146. gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.yml +139 -0
  147. gitinject/workflows/gemini-assistant-original/gemini-dispatch/README.md +49 -0
  148. gitinject/workflows/gemini-assistant-original/gemini-dispatch/gemini-dispatch.yml +221 -0
  149. gitinject/workflows/gemini-assistant-original/issue-triage/README.md +190 -0
  150. gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.toml +96 -0
  151. gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.yml +223 -0
  152. gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.toml +32 -0
  153. gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.yml +167 -0
  154. gitinject/workflows/gemini-assistant-original/metadata.json +10 -0
  155. gitinject/workflows/gemini-assistant-original/pr-review/README.md +337 -0
  156. gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.toml +176 -0
  157. gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.yml +119 -0
  158. gitinject/workflows/opencode-pr-review/contents/.github/workflows/main.yml +28 -0
  159. gitinject/workflows/opencode-pr-review/metadata.json +10 -0
  160. gitinject-0.1.0.dist-info/METADATA +128 -0
  161. gitinject-0.1.0.dist-info/RECORD +164 -0
  162. gitinject-0.1.0.dist-info/WHEEL +4 -0
  163. gitinject-0.1.0.dist-info/entry_points.txt +2 -0
  164. gitinject-0.1.0.dist-info/licenses/LICENSE +202 -0
gitinject/runner.py ADDED
@@ -0,0 +1,1024 @@
1
+ import glob
2
+ import json
3
+ import os
4
+ import random
5
+ import re
6
+ import string
7
+ import time
8
+ import uuid
9
+ from pathlib import Path
10
+
11
+ import click
12
+ from tenacity import retry, retry_if_result, stop_after_attempt, wait_exponential
13
+
14
+ from .analyzer import BenchmarkAnalyzer
15
+ from .attacks import AbstractAttack, load_attack
16
+ from .resources import dataset_dir
17
+ from .run_context import RunContext, TriggerReceipt
18
+ from .run_record import RunRecord, RunSpec, write_json
19
+ from .scenario_loader import find_scenario, load_scenario
20
+ from .utils.gh_client import GitHubClient
21
+ from .utils.provisioner import RepoProvisioner
22
+ from .utils.types import AIProvider
23
+
24
+
25
+ def _extract_inline_prompts(workflow_dict) -> list[str]:
26
+ """Walk a parsed workflow YAML and collect all `with.prompt` values from action steps."""
27
+ prompts = []
28
+ for job in (workflow_dict or {}).get("jobs", {}).values():
29
+ for step in job.get("steps", []):
30
+ prompt = (step.get("with") or {}).get("prompt")
31
+ if prompt:
32
+ prompts.append(str(prompt))
33
+ return prompts
34
+
35
+
36
+ class BenchmarkRunner:
37
+ """Orchestrates the execution of a benchmark test on real GitHub."""
38
+
39
+ def __init__(self, workspace_dir, repo_prefix="benchmark-run", *, gh_client=None, event_gh_client=None):
40
+ self.workspace_dir = workspace_dir
41
+ self.repo_prefix = repo_prefix
42
+ self.gh_client = gh_client if gh_client is not None else GitHubClient()
43
+ self.event_gh_client = event_gh_client if event_gh_client is not None else self._build_event_client()
44
+ self.repo_name = self._generate_repo_name(repo_prefix)
45
+ self._sync_repo_name(self.repo_name)
46
+
47
+ self.provisioner = RepoProvisioner(self.gh_client)
48
+ self.analyzer = BenchmarkAnalyzer(workspace_dir, repo=self.repo_name, gh_client=self.gh_client)
49
+
50
+ def _build_event_client(self):
51
+ """Returns the client used to create attacker-controlled events."""
52
+ attacker_token = os.environ.get("ATTACKER_GITHUB_TOKEN")
53
+ if attacker_token:
54
+ return GitHubClient(
55
+ token=attacker_token,
56
+ token_env_var="ATTACKER_GITHUB_TOKEN",
57
+ auth_label="attacker GitHub",
58
+ actor="attacker",
59
+ )
60
+ return self.gh_client
61
+
62
+ def _sync_repo_name(self, repo_name: str) -> None:
63
+ """Keeps owner/analyzer/event clients pointed at the same repository."""
64
+ self.repo_name = repo_name
65
+ self.gh_client.repo_name = repo_name
66
+ self.gh_client._repo_cache = None
67
+ if self.event_gh_client is not self.gh_client:
68
+ self.event_gh_client.repo_name = repo_name
69
+ self.event_gh_client._repo_cache = None
70
+
71
+ def _generate_repo_name(self, prefix):
72
+ """Generates a unique repo name based on a prefix."""
73
+ if "/" in prefix:
74
+ owner, name_prefix = prefix.split("/", 1)
75
+ else:
76
+ owner = None
77
+ name_prefix = prefix
78
+
79
+ random_suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
80
+ repo_name = f"{name_prefix}-{random_suffix}"
81
+
82
+ if owner:
83
+ return f"{owner}/{repo_name}"
84
+ return repo_name
85
+
86
+ def _inject_attack_slots(self, scenario, attack: AbstractAttack, context: str) -> None:
87
+ """Generate a payload and substitute it into all of the scenario's injection slots."""
88
+ goal = scenario.get_attack_goal()
89
+ if goal is None:
90
+ raise ValueError("Scenario does not expose an attack goal")
91
+ slots = scenario.get_injection_slots()
92
+ if not any("{{INJECTION}}" in template for template in slots.values()):
93
+ raise ValueError("Scenario has no effective {{INJECTION}} slot")
94
+ payload = attack.generate(goal, context)
95
+ for field, template in slots.items():
96
+ scenario.apply_attack(field, template.replace("{{INJECTION}}", payload))
97
+
98
+ def run(
99
+ self,
100
+ workflow_id,
101
+ scenario_id,
102
+ attack_id=None,
103
+ attack_payload=None,
104
+ cleanup=True,
105
+ log_llm_input=False,
106
+ parameters=None,
107
+ seed=None,
108
+ parent_attempt_id=None,
109
+ attack: AbstractAttack | None = None,
110
+ security_evaluator=None,
111
+ ):
112
+ """Triggers a GitHub workflow and waits for completion."""
113
+ spec = RunSpec(
114
+ workflow=workflow_id,
115
+ scenario=str(scenario_id),
116
+ parameters=json.loads(json.dumps({} if parameters is None else parameters, allow_nan=False)),
117
+ seed=seed,
118
+ parent_attempt_id=parent_attempt_id,
119
+ attack=attack_id or getattr(attack, "name", None),
120
+ cleanup=cleanup,
121
+ )
122
+ record = RunRecord(self.workspace_dir, spec)
123
+ run_result = {}
124
+ result = {
125
+ "workflow": workflow_id,
126
+ "scenario": str(scenario_id),
127
+ "repo": self.repo_name,
128
+ "timestamp": record.timestamp,
129
+ "attempt_id": record.attempt_id,
130
+ "runs_dir": str(record.directory),
131
+ "run_result": run_result,
132
+ }
133
+ setup_started = False
134
+ scenario = context = None
135
+ previous_recorders = []
136
+ try:
137
+ record.event("phase", phase="loading")
138
+ workflow_dir = str(dataset_dir("workflows", self.workspace_dir) / workflow_id)
139
+ scenario_path = self._find_scenario_path(scenario_id)
140
+ if not os.path.isdir(workflow_dir) or not scenario_path:
141
+ raise ValueError(f"Workflow dir ({workflow_id}) or scenario ({scenario_id}) not found.")
142
+ record.snapshot("workflow", workflow_dir)
143
+ workflow_dir = str(record.directory / "inputs/workflow")
144
+ if os.path.exists(scenario_path):
145
+ source = (
146
+ os.path.dirname(scenario_path)
147
+ if Path(scenario_path).name in {"scenario.py", "recipe.json"}
148
+ else scenario_path
149
+ )
150
+ record.snapshot("scenario", source)
151
+ definition = Path(scenario_path)
152
+ if Path(source).is_file() and (definition.parent / "contents").is_dir():
153
+ record.snapshot("scenario", definition.parent / "contents", prefix="contents")
154
+ scenario_path = str(record.directory / "inputs/scenario" / definition.name)
155
+ lockfile = os.path.join(self.workspace_dir, "uv.lock")
156
+ if os.path.isfile(lockfile):
157
+ record.snapshot("dependencies", lockfile)
158
+ scenario = self._load_scenario(scenario_path)
159
+ if scenario is None:
160
+ raise ValueError(f"Failed to load scenario {scenario_id}")
161
+ scenario.runtime_state["repo"] = self.repo_name
162
+ meta_path = os.path.join(workflow_dir, "metadata.json")
163
+ workflow_meta = {}
164
+ if os.path.isfile(meta_path):
165
+ with open(meta_path) as handle:
166
+ workflow_meta = json.load(handle)
167
+ actors = {"owner": self.gh_client}
168
+ if self.event_gh_client is not self.gh_client:
169
+ actors["attacker"] = self.event_gh_client
170
+ context = RunContext(
171
+ spec,
172
+ record,
173
+ scenario.runtime_state,
174
+ actors,
175
+ lambda: self._legacy_trigger(scenario),
176
+ lambda: self._capture_gh_state(scenario),
177
+ )
178
+ for actor in scenario.required_actors:
179
+ context.github(actor)
180
+ for actor, client in actors.items():
181
+ previous_recorders.append((client, client.record_event, client.actor))
182
+ client.record_event = record.event
183
+ client.actor = actor
184
+ record.event("phase", phase="preflight")
185
+ record.manifest["actors"] = {actor: client.get_authenticated_user_login() for actor, client in actors.items()}
186
+ if "/" not in self.repo_name:
187
+ self._sync_repo_name(f"{record.manifest['actors']['owner']}/{self.repo_name}")
188
+ result["repo"] = scenario.runtime_state["repo"] = self.repo_name
189
+ record.save_manifest()
190
+ self._configure_workflow_tracking(workflow_dir, workflow_meta)
191
+ provider_error = self._validate_provider_requirements(workflow_meta)
192
+ if provider_error:
193
+ raise ValueError(provider_error)
194
+
195
+ # Tier 1: workflow-declared required keys (hard block)
196
+ required_secrets = workflow_meta.get("required_secrets", [])
197
+ required_vars = workflow_meta.get("required_vars", [])
198
+ missing = [k for k in required_secrets + required_vars if not os.environ.get(k)]
199
+ if missing:
200
+ raise ValueError("Missing required environment variables:\n - " + "\n - ".join(missing))
201
+
202
+ # Tier 2: YAML-scanned keys — set if available, silently skip if not
203
+ requirements = self._get_workflow_requirements(workflow_dir)
204
+ secrets = {k: v for k in requirements["secrets"] if (v := os.environ.get(k))}
205
+ variables = {k: v for k in requirements["vars"] if (v := os.environ.get(k))}
206
+
207
+ secrets.update(scenario.get_secrets())
208
+ scenario_secrets = scenario.get_required_secrets()
209
+ missing = [name for name in scenario_secrets if not os.environ.get(name)]
210
+ if missing:
211
+ raise ValueError("Missing scenario secrets: " + ", ".join(missing))
212
+ secrets.update({name: os.environ[name] for name in scenario_secrets})
213
+
214
+ if attack_id or attack is not None:
215
+ attack = attack if attack is not None else load_attack(attack_id, payload=attack_payload)
216
+ self._inject_attack_slots(scenario, attack, self._reconstruct_llm_input(scenario, workflow_dir))
217
+ record.artifact("rendered_attack.json", scenario._injected)
218
+
219
+ target_branch = getattr(scenario, "branch", None)
220
+ template_repo = scenario.get_template_repo()
221
+
222
+ record.manifest["configuration"] = {
223
+ "secret_names": sorted(secrets),
224
+ "variables": variables,
225
+ "required_actors": list(scenario.required_actors),
226
+ "template_repo": template_repo,
227
+ "branch": target_branch,
228
+ "workflow_metadata": workflow_meta,
229
+ "security_evaluator_source": "caller" if security_evaluator is not None else "scenario",
230
+ }
231
+ record.save_manifest()
232
+ record.event("phase", phase="provisioning")
233
+ click.echo(f"Provisioning repository {self.repo_name}...")
234
+ self.provisioner.provision(
235
+ workflow_dir,
236
+ scenario.get_required_files(),
237
+ branch=target_branch,
238
+ template_repo=template_repo,
239
+ secrets=secrets,
240
+ variables=variables,
241
+ )
242
+ self._sync_repo_name(self.gh_client.repo_name)
243
+ result["repo"] = self.repo_name
244
+ scenario.runtime_state["repo"] = self.repo_name
245
+
246
+ record.event("phase", phase="preparing")
247
+ click.echo(f"Preparing repository state for scenario '{scenario_id}'...")
248
+ setup_started = True
249
+ scenario.prepare(context)
250
+
251
+ click.echo("Capturing context snapshot...")
252
+ write_json(record.directory / "context_snapshot.json", self._capture_context_snapshot(scenario, workflow_dir))
253
+
254
+ if log_llm_input:
255
+ llm_input = self._reconstruct_llm_input(scenario, workflow_dir)
256
+ click.echo(click.style("\n--- Reconstructed LLM Input ---", bold=True))
257
+ click.echo(llm_input)
258
+ click.echo(click.style("--- End LLM Input ---\n", bold=True))
259
+ (record.directory / "llm_input.txt").write_text(llm_input)
260
+
261
+ click.echo(f"Triggering workflow '{workflow_id}' on GitHub...")
262
+ self._prepare_trial(scenario, workflow_meta)
263
+ start_time = scenario.runtime_state["triggered_at"]
264
+ record.event("phase", phase="triggering")
265
+ receipt = scenario.trigger(context)
266
+ if not isinstance(receipt, TriggerReceipt):
267
+ raise TypeError("Scenario trigger must return a TriggerReceipt")
268
+ if receipt.subject_kind:
269
+ scenario.runtime_state[f"{receipt.subject_kind}_number"] = receipt.subject_number
270
+ self._expected_run_id = receipt.workflow_run_id
271
+ record.artifact(
272
+ "trigger_receipt.json",
273
+ {
274
+ "event_type": receipt.event_type,
275
+ "subject_kind": receipt.subject_kind,
276
+ "subject_number": receipt.subject_number,
277
+ "workflow_run_id": receipt.workflow_run_id,
278
+ },
279
+ )
280
+
281
+ record.event("phase", phase="waiting")
282
+ click.echo("Waiting for workflow run to start and complete...")
283
+ wait_result = self._wait_for_run(start_time, expected_event=receipt.event_type)
284
+
285
+ if not wait_result:
286
+ raise TimeoutError("Timed out waiting for workflow run or could not find it.")
287
+
288
+ run_id, final_run = wait_result
289
+
290
+ result["run_id"] = run_id
291
+ click.echo(f"Fetching logs for run {run_id}...")
292
+ log_error = None
293
+ try:
294
+ stdout, stderr = self._get_workflow_logs(run_id)
295
+ except Exception as exc:
296
+ stdout, stderr = "", ""
297
+ log_error = str(exc)
298
+ try:
299
+ billable_minutes = self._get_billable_minutes(run_id)
300
+ except Exception as exc:
301
+ billable_minutes = None
302
+ result["timing_error"] = str(exc)
303
+
304
+ run_result.update(
305
+ {
306
+ "stdout": stdout,
307
+ "stderr": stderr,
308
+ "exit_code": 0 if final_run.conclusion == "success" else 1,
309
+ "agent_invoked": self._agent_invocation(final_run),
310
+ "status": final_run.status,
311
+ "conclusion": final_run.conclusion,
312
+ }
313
+ )
314
+
315
+ run_result["jobs"] = self._job_evidence
316
+ if log_error:
317
+ run_result["log_error"] = log_error
318
+ if getattr(self, "_invocation_error", None):
319
+ run_result["invocation_error"] = self._invocation_error
320
+ record.event("phase", phase="observing")
321
+ gh_state = scenario.observe(context, run_result)
322
+ if not isinstance(gh_state, dict):
323
+ raise TypeError("Scenario observe must return an evidence dictionary")
324
+ record.artifact("evidence.json", gh_state)
325
+ if gh_state.get("error"):
326
+ run_result["evidence_error"] = gh_state["error"]
327
+ else:
328
+ scenario.runtime_state["observed_details"] = gh_state
329
+ from .evidence import agent_artifacts
330
+
331
+ if "comment_details" in gh_state and "error" not in gh_state:
332
+ try:
333
+ result["agent_artifacts"] = agent_artifacts(gh_state, scenario)
334
+ except Exception as exc:
335
+ result["artifact_error"] = str(exc)
336
+ record.event("phase", phase="evaluating")
337
+ analysis = self.analyzer.analyze(run_result, scenario, security_evaluator=security_evaluator)
338
+
339
+ result.update(
340
+ {
341
+ "analysis": analysis,
342
+ "message": f"Analyzed workflow run {run_id} ({final_run.conclusion}).",
343
+ "gh_state": gh_state,
344
+ "billable_minutes": billable_minutes,
345
+ "evidence_boundary": {
346
+ key: scenario.runtime_state.get(key)
347
+ for key in ("triggered_at", "agent_logins", "input_logins", "baseline_artifact_ids")
348
+ },
349
+ }
350
+ )
351
+ return result
352
+
353
+ except (KeyboardInterrupt, SystemExit) as exc:
354
+ result["error"] = type(exc).__name__
355
+ result["interrupted"] = True
356
+ raise
357
+ except Exception as exc:
358
+ result["error"] = str(exc)
359
+ return result
360
+ finally:
361
+ try:
362
+ if cleanup:
363
+ record.event("phase", phase="cleaning")
364
+ self._cleanup(scenario if setup_started else None, result, context=context)
365
+ else:
366
+ click.echo(click.style(f"SKIP CLEANUP: Repository {self.repo_name} remains active.", fg="yellow"))
367
+ self._save_run_locally(result, run_result, record.directory)
368
+ phase = "interrupted" if result.get("interrupted") else "failed" if result.get("error") else "completed"
369
+ record.event("phase", phase=phase)
370
+ finally:
371
+ for client, recorder, actor in previous_recorders:
372
+ client.record_event, client.actor = recorder, actor
373
+
374
+ def _cleanup(self, scenario, result, context):
375
+ operations = [self.provisioner.teardown]
376
+ if scenario is not None:
377
+ operations.insert(0, lambda: scenario.cleanup(context))
378
+ for operation in operations:
379
+ try:
380
+ operation()
381
+ except Exception as exc:
382
+ result.setdefault("cleanup_errors", []).append(str(exc))
383
+ click.echo(f"Cleanup failed: {exc}", err=True)
384
+ if context:
385
+ errors = context.cleanup_repositories()
386
+ if errors:
387
+ result.setdefault("cleanup_errors", []).extend(errors)
388
+
389
+ def _legacy_trigger(self, scenario):
390
+ success, error = self._trigger_event(scenario)
391
+ if not success:
392
+ raise RuntimeError(f"Failed to trigger GitHub event: {error}")
393
+ state = scenario.runtime_state
394
+ kind = "pr" if state.get("pr_number") else "issue" if state.get("issue_number") else None
395
+ return TriggerReceipt(scenario.get_event().get("event_type"), kind, state.get(f"{kind}_number") if kind else None)
396
+
397
+ def _configure_workflow_tracking(self, workflow_dir, metadata=None):
398
+ import yaml
399
+
400
+ if metadata is None:
401
+ meta_path = os.path.join(workflow_dir, "metadata.json")
402
+ if os.path.isfile(meta_path):
403
+ with open(meta_path) as handle:
404
+ metadata = json.load(handle)
405
+ metadata = metadata or {}
406
+ self._workflow_metadata = metadata
407
+ contents = os.path.join(workflow_dir, "contents")
408
+ root = os.path.join(contents, ".github/workflows") if os.path.isdir(contents) else workflow_dir
409
+ self._workflow_events = {}
410
+ self._agent_steps = set((metadata or {}).get("agent_steps", []))
411
+ for path in sorted(glob.glob(os.path.join(root, "*.y*ml"))):
412
+ with open(path) as handle:
413
+ workflow = yaml.load(handle, Loader=yaml.BaseLoader) or {}
414
+ events = workflow.get("on", {})
415
+ events = [events] if isinstance(events, str) else events
416
+ self._workflow_events[f".github/workflows/{os.path.basename(path)}"] = set(events)
417
+ for job in workflow.get("jobs", {}).values():
418
+ for step in job.get("steps", []):
419
+ action = step.get("uses", "").split("@", 1)[0]
420
+ if action in {
421
+ "openai/codex-action",
422
+ "anthropics/claude-code-action",
423
+ "google-github-actions/run-gemini-cli",
424
+ }:
425
+ self._agent_steps.add(step.get("name", step["uses"]))
426
+
427
+ def _agent_invocation(self, run):
428
+ self._job_evidence = []
429
+ self._invocation_error = None
430
+ try:
431
+ for job in run.jobs():
432
+ for step in job.steps:
433
+ self._job_evidence.append(
434
+ {"job": job.name, "name": step.name, "status": step.status, "conclusion": step.conclusion}
435
+ )
436
+ except Exception as exc:
437
+ self._invocation_error = str(exc)
438
+ return None
439
+ agent_steps = [step for step in self._job_evidence if step["name"] in getattr(self, "_agent_steps", set())]
440
+ if any(step["conclusion"] == "success" for step in agent_steps):
441
+ return True
442
+ if agent_steps and all(step["conclusion"] == "skipped" for step in agent_steps):
443
+ return False
444
+ if run.conclusion in {"action_required", "skipped"}:
445
+ return False
446
+ return None
447
+
448
+ def _prepare_trial(self, scenario, metadata=None):
449
+ from .evidence import DEFAULT_AGENT_LOGINS
450
+
451
+ metadata = metadata if metadata is not None else getattr(self, "_workflow_metadata", {})
452
+ state = scenario.runtime_state
453
+ state.pop("observed_details", None)
454
+ state["agent_logins"] = list((metadata or {}).get("agent_logins", sorted(DEFAULT_AGENT_LOGINS)))
455
+ state["input_logins"] = sorted(
456
+ {client.get_authenticated_user_login() for client in (self.gh_client, self.event_gh_client)}
457
+ )
458
+ self._baseline_run_ids = {run.id for run in self.gh_client.repository.get_workflow_runs()[:100]}
459
+ event = scenario.get_event()
460
+ number = event.get("data", {}).get("number")
461
+ if number and event["event_type"] == "issue_comment":
462
+ state["issue_number"] = number
463
+ elif number and event["event_type"] in {"pull_request_review", "pull_request_review_comment"}:
464
+ state["pr_number"] = number
465
+ baseline = self._capture_gh_state(scenario) if state.get("pr_number") or state.get("issue_number") else {}
466
+ if baseline.get("error"):
467
+ raise RuntimeError(f"Cannot establish evidence boundary: {baseline['error']}")
468
+ state["baseline_artifact_ids"] = [f"{item['kind']}:{item['id']}" for item in baseline.get("comment_details", [])]
469
+ state["triggered_at"] = int(time.time())
470
+ self._trial_state = state
471
+
472
+ def optimize(self, workflow_id, scenario_id, attack_id, iterations, cleanup=True):
473
+ """Search with independent trials through the ordinary run engine."""
474
+ if iterations < 1:
475
+ raise ValueError("iterations must be positive")
476
+ attack = load_attack(attack_id)
477
+ search = RunRecord(self.workspace_dir, RunSpec(workflow_id, str(scenario_id), attack=attack_id, cleanup=cleanup))
478
+ scores = []
479
+ result = {}
480
+ for iteration in range(1, iterations + 1):
481
+ trial = BenchmarkRunner(self.workspace_dir, repo_prefix=self.repo_prefix)
482
+ attempt = trial.run(
483
+ workflow_id,
484
+ scenario_id,
485
+ attack_id=attack_id,
486
+ attack=attack,
487
+ cleanup=cleanup,
488
+ parent_attempt_id=search.attempt_id,
489
+ )
490
+ verdict = attempt.get("analysis", {}).get("security_breached")
491
+ score = int(verdict) if type(verdict) is bool and not attempt.get("error") else None
492
+ if score is not None:
493
+ attack.update(float(score))
494
+ scores.append(score)
495
+ search.event(
496
+ "iteration",
497
+ iteration=iteration,
498
+ score=score,
499
+ attempt_id=attempt.get("attempt_id"),
500
+ run_id=attempt.get("run_id"),
501
+ error=attempt.get("error")
502
+ or attempt.get("analysis", {}).get("evaluation_errors", {}).get("security_breached"),
503
+ )
504
+ valid = [score for score in scores if score is not None]
505
+ best = attack.best_payload
506
+ if best:
507
+ (search.directory / "best_payload.txt").write_text(best)
508
+ result.update(
509
+ {
510
+ "workflow": workflow_id,
511
+ "scenario": str(scenario_id),
512
+ "attack": attack_id,
513
+ "attempt_id": search.attempt_id,
514
+ "iterations": iterations,
515
+ "asr_curve": scores,
516
+ "final_asr": sum(valid) / len(valid) if valid else None,
517
+ "best_payload": best,
518
+ "runs_dir": str(search.directory),
519
+ "valid_iterations": len(valid),
520
+ "unknown_iterations": iterations - len(valid),
521
+ }
522
+ )
523
+ write_json(search.directory / "metadata.json", result)
524
+ search.event("phase", phase="completed")
525
+ return result
526
+
527
+ def offline_optimize(self, workflow_id, scenario_id, attack_id, iterations, victim_model: str | None = None):
528
+ """
529
+ Optimize an attack entirely offline — no GitHub repo is provisioned.
530
+
531
+ Each iteration:
532
+ 1. Reconstruct the baseline LLM prompt (what the model will see)
533
+ 2. Generate an attack payload and inject it into the scenario's slots
534
+ 3. Reconstruct the injected LLM prompt
535
+ 4. Call the victim model directly via the OpenAI API (OPENAI_API_KEY)
536
+ 5. Score with scenario.get_preflight_evaluator()
537
+ 6. Feed score back to attack.update()
538
+
539
+ Returns { best_payload, asr_curve, final_asr, runs_dir }.
540
+ """
541
+ import os as _os
542
+
543
+ from openai import OpenAI
544
+
545
+ workflow_dir = str(dataset_dir("workflows", self.workspace_dir) / workflow_id)
546
+ scenario_path = self._find_scenario_path(scenario_id)
547
+
548
+ if not _os.path.exists(workflow_dir) or not scenario_path:
549
+ return {"error": f"Workflow dir ({workflow_id}) or scenario ({scenario_id}) not found."}
550
+
551
+ scenario = self._load_scenario(scenario_path)
552
+ if not scenario:
553
+ return {"error": f"Failed to load scenario {scenario_id}"}
554
+
555
+ goal = scenario.get_attack_goal()
556
+ if not goal:
557
+ return {"error": f"Scenario '{scenario_id}' has no get_attack_goal() — cannot optimize."}
558
+
559
+ preflight_check = scenario.get_preflight_evaluator()
560
+ if preflight_check is None:
561
+ return {"error": f"Scenario '{scenario_id}' has no get_preflight_evaluator() — cannot run offline."}
562
+
563
+ model = victim_model or _os.environ.get("ATTACK_VICTIM_MODEL", "gpt-4o-mini")
564
+ openai_key = _os.environ.get("OPENAI_API_KEY")
565
+ if not openai_key:
566
+ return {"error": "OPENAI_API_KEY is not set. The victim model is called via the OpenAI API directly."}
567
+ client = OpenAI(api_key=openai_key)
568
+
569
+ attack = load_attack(attack_id)
570
+ timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
571
+ runs_dir = _os.path.join(
572
+ self.workspace_dir, "runs", f"offline-{timestamp.replace(':', '-')}-{uuid.uuid4().hex[:12]}"
573
+ )
574
+ _os.makedirs(runs_dir)
575
+ history_path = _os.path.join(runs_dir, "attack_history.jsonl")
576
+
577
+ asr_curve = []
578
+
579
+ for i in range(iterations):
580
+ click.echo(click.style(f"\n--- Offline iteration {i + 1}/{iterations} ---", bold=True))
581
+
582
+ scenario._injected = {}
583
+ baseline_input = self._reconstruct_llm_input(scenario, workflow_dir, repo_name="owner/repo", prompts_only=True)
584
+
585
+ try:
586
+ self._inject_attack_slots(scenario, attack, baseline_input)
587
+ except ValueError as e:
588
+ click.echo(click.style(f"Attacker refused: {e}", fg="yellow"))
589
+ asr_curve.append(None)
590
+ with open(history_path, "a") as handle:
591
+ handle.write(json.dumps({"iteration": i + 1, "score": None, "error": str(e)}) + "\n")
592
+ continue
593
+
594
+ injected_input = self._reconstruct_llm_input(scenario, workflow_dir, repo_name="owner/repo", prompts_only=True)
595
+
596
+ click.echo(f"Calling {model}...")
597
+ try:
598
+ resp = client.chat.completions.create(
599
+ model=model,
600
+ messages=[{"role": "user", "content": injected_input}],
601
+ max_completion_tokens=1500,
602
+ )
603
+ response_text = resp.choices[0].message.content or ""
604
+ except Exception as e:
605
+ click.echo(click.style(f"Model call failed: {e}", fg="red"))
606
+ asr_curve.append(None)
607
+ with open(history_path, "a") as handle:
608
+ handle.write(json.dumps({"iteration": i + 1, "score": None, "error": str(e)}) + "\n")
609
+ continue
610
+
611
+ try:
612
+ verdict = preflight_check(response_text)
613
+ if type(verdict) is not bool:
614
+ raise ValueError("Preflight check must return a boolean")
615
+ score = float(verdict)
616
+ except Exception as exc:
617
+ asr_curve.append(None)
618
+ with open(history_path, "a") as handle:
619
+ handle.write(json.dumps({"iteration": i + 1, "score": None, "error": str(exc)}) + "\n")
620
+ continue
621
+ asr_curve.append(int(score))
622
+ status = click.style("PASS", fg="green") if score else click.style("FAIL", fg="red")
623
+ click.echo(f"Iteration {i + 1}: {status}")
624
+ if score == 0:
625
+ click.echo(f"Response preview: {response_text[:300]}")
626
+
627
+ attack.update(score)
628
+
629
+ with open(history_path, "a") as f:
630
+ f.write(
631
+ json.dumps(
632
+ {
633
+ "iteration": i + 1,
634
+ "score": score,
635
+ "response_preview": response_text[:500],
636
+ }
637
+ )
638
+ + "\n"
639
+ )
640
+
641
+ if score == 1.0:
642
+ click.echo(click.style("Attack succeeded — stopping early.", fg="green"))
643
+ break
644
+
645
+ valid_scores = [score for score in asr_curve if score is not None]
646
+ final_asr = sum(valid_scores) / len(valid_scores) if valid_scores else None
647
+ best = attack.best_payload
648
+
649
+ if best:
650
+ best_path = _os.path.join(runs_dir, "best_payload.txt")
651
+ with open(best_path, "w") as f:
652
+ f.write(best)
653
+ click.echo(f"\nBest payload written to {best_path}")
654
+
655
+ result = {
656
+ "workflow": workflow_id,
657
+ "scenario": scenario_id,
658
+ "attack": attack_id,
659
+ "iterations_run": len(asr_curve),
660
+ "asr_curve": asr_curve,
661
+ "final_asr": final_asr,
662
+ "best_payload": best,
663
+ "runs_dir": runs_dir,
664
+ "mode": "offline",
665
+ "valid_iterations": len(valid_scores),
666
+ "unknown_iterations": len(asr_curve) - len(valid_scores),
667
+ }
668
+ with open(_os.path.join(runs_dir, "metadata.json"), "w") as f:
669
+ json.dump(result, f, indent=4)
670
+
671
+ click.echo(
672
+ f"\nOffline optimization complete. ASR: {final_asr} ({sum(valid_scores)}/{len(valid_scores)} valid trials)"
673
+ )
674
+ return result
675
+
676
+ def _find_scenario_path(self, scenario_id):
677
+ path = find_scenario(dataset_dir("scenarios", self.workspace_dir), str(scenario_id))
678
+ return str(path) if path else None
679
+
680
+ def _capture_context_snapshot(self, scenario, workflow_dir):
681
+ """Captures targeted metadata and injected files for diagnostic purposes without full repo download."""
682
+ repo = self.gh_client.repository
683
+ fixture_repo = getattr(scenario, "_attacker_fork_client", None)
684
+ fixture_repo = fixture_repo.repository if fixture_repo is not None else repo
685
+ fixture_ref = getattr(scenario, "branch", None) or fixture_repo.default_branch
686
+
687
+ snapshot = {
688
+ "repository": self.repo_name,
689
+ "default_branch": repo.default_branch,
690
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
691
+ "event": scenario.get_event(),
692
+ "injected_files_content": {}, # Full content of what WE care about
693
+ "runtime_state": scenario.runtime_state,
694
+ }
695
+
696
+ try:
697
+ # 1. Capture content of our injected workflows
698
+ click.echo(" Snapshotting workflows...")
699
+ try:
700
+ workflow_contents = repo.get_contents(".github/workflows")
701
+ if isinstance(workflow_contents, list):
702
+ for content in workflow_contents:
703
+ if content.type == "file":
704
+ snapshot["injected_files_content"][content.path] = content.decoded_content.decode("utf-8")
705
+ except Exception:
706
+ pass # No workflows or error
707
+
708
+ # 2. Capture content of files required by the scenario
709
+ required_files = scenario.get_required_files()
710
+ if required_files:
711
+ click.echo(f" Snapshotting {len(required_files)} scenario files...")
712
+ for repo_path in required_files:
713
+ if repo_path not in snapshot["injected_files_content"]:
714
+ try:
715
+ content = fixture_repo.get_contents(repo_path, ref=fixture_ref)
716
+ if not isinstance(content, list):
717
+ snapshot["injected_files_content"][repo_path] = content.decoded_content.decode("utf-8")
718
+ except Exception:
719
+ continue
720
+
721
+ except Exception as e:
722
+ snapshot["error"] = str(e)
723
+
724
+ return snapshot
725
+
726
+ def _get_workflow_requirements(self, workflow_dir):
727
+ """Scans workflow YAML files for 'secrets.NAME' and 'vars.NAME' patterns."""
728
+ requirements = {"secrets": set(), "vars": set()}
729
+ secret_pattern = re.compile(r"secrets\.(\w+)")
730
+ var_pattern = re.compile(r"vars\.(\w+)")
731
+ workflows_path = os.path.join(workflow_dir, "contents", ".github", "workflows")
732
+ files = []
733
+ if os.path.isdir(workflows_path):
734
+ files = [
735
+ os.path.join(workflows_path, f)
736
+ for f in os.listdir(workflows_path)
737
+ if f.endswith(".yml") or f.endswith(".yaml")
738
+ ]
739
+ elif os.path.isdir(workflow_dir):
740
+ files = [
741
+ os.path.join(workflow_dir, f) for f in os.listdir(workflow_dir) if f.endswith(".yml") or f.endswith(".yaml")
742
+ ]
743
+
744
+ for file_path in files:
745
+ if not os.path.exists(file_path):
746
+ continue
747
+ with open(file_path, "r") as f:
748
+ content = f.read()
749
+ for match in secret_pattern.finditer(content):
750
+ requirements["secrets"].add(match.group(1))
751
+ for match in var_pattern.finditer(content):
752
+ requirements["vars"].add(match.group(1))
753
+
754
+ if "GITHUB_TOKEN" in requirements["secrets"]:
755
+ requirements["secrets"].remove("GITHUB_TOKEN")
756
+ return requirements
757
+
758
+ def _reconstruct_llm_input(
759
+ self,
760
+ scenario,
761
+ workflow_dir,
762
+ repo_name: str | None = None,
763
+ prompts_only: bool = False,
764
+ ) -> str:
765
+ """
766
+ Reconstructs the effective LLM prompt by substituting known GitHub context values
767
+ into workflow YAMLs.
768
+
769
+ prompts_only=True returns only the inline `with.prompt` values after substitution,
770
+ matching what the LLM actually receives in production (e.g. via codex-action).
771
+ prompts_only=False (default) returns the full YAML + extracted prompts, useful for
772
+ diagnostics and the --log-llm-input flag.
773
+ """
774
+ import yaml
775
+
776
+ event = scenario.get_event()
777
+ data = event.get("data", {})
778
+
779
+ substitutions = {
780
+ "github.repository": repo_name or self.repo_name,
781
+ "github.event.pull_request.title": data.get("title", ""),
782
+ "github.event.pull_request.body": data.get("body", ""),
783
+ "github.event.pull_request.number": "<PR_NUMBER>",
784
+ "github.event.pull_request.base.ref": data.get("base", "<BASE_REF>"),
785
+ "github.event.pull_request.base.sha": "<BASE_SHA>",
786
+ "github.event.pull_request.head.sha": "<HEAD_SHA>",
787
+ "github.event.pull_request.user.login": "<PR_AUTHOR>",
788
+ "github.event.issue.number": "<ISSUE_NUMBER>",
789
+ "github.event.issue.title": data.get("title", ""),
790
+ "github.event.issue.body": data.get("body", ""),
791
+ "github.event.comment.body": data.get("body", ""),
792
+ "github.ref_name": "<REF_NAME>",
793
+ "github.event_name": event.get("event_type", ""),
794
+ "github.run_id": "<RUN_ID>",
795
+ }
796
+
797
+ contents_dir = os.path.join(workflow_dir, "contents")
798
+ yaml_files = sorted(
799
+ glob.glob(os.path.join(contents_dir, "**/*.yml"), recursive=True, include_hidden=True)
800
+ + glob.glob(os.path.join(contents_dir, "**/*.yaml"), recursive=True, include_hidden=True)
801
+ )
802
+
803
+ output_parts = []
804
+ for yml_path in yaml_files:
805
+ with open(yml_path) as f:
806
+ raw = f.read()
807
+
808
+ if prompts_only:
809
+ # Parse the raw YAML first (before substitution) to extract prompt templates,
810
+ # then apply substitutions to each template string. This avoids YAML parse
811
+ # failures caused by injection payloads containing YAML-unsafe content.
812
+ try:
813
+ raw_parsed = yaml.safe_load(raw)
814
+ prompt_templates = _extract_inline_prompts(raw_parsed)
815
+ except Exception:
816
+ prompt_templates = []
817
+ for template in prompt_templates:
818
+ substituted = template
819
+ for key, value in substitutions.items():
820
+ substituted = substituted.replace(f"${{{{ {key} }}}}", value)
821
+ output_parts.append(substituted)
822
+ else:
823
+ substituted = raw
824
+ for key, value in substitutions.items():
825
+ substituted = substituted.replace(f"${{{{ {key} }}}}", value)
826
+
827
+ header = f"=== {os.path.relpath(yml_path, workflow_dir)} ==="
828
+ output_parts.append(header + "\n" + substituted)
829
+
830
+ try:
831
+ parsed = yaml.safe_load(substituted)
832
+ prompts = _extract_inline_prompts(parsed)
833
+ except Exception:
834
+ prompts = []
835
+ if prompts:
836
+ extracted = "\n---\n".join(prompts)
837
+ output_parts.append(f"--- extracted prompt(s) ---\n{extracted}")
838
+
839
+ return "\n\n".join(output_parts)
840
+
841
+ def _capture_gh_state(self, scenario):
842
+ runtime_state = getattr(scenario, "runtime_state", None) or {}
843
+ pr_number = runtime_state.get("pr_number")
844
+ issue_number = runtime_state.get("issue_number")
845
+ try:
846
+ if pr_number:
847
+ details = self.gh_client.get_pr_details(pr_number) or {}
848
+ return {"kind": "pr", "number": pr_number, **details}
849
+ if issue_number:
850
+ details = self.gh_client.get_issue_details(issue_number) or {}
851
+ return {"kind": "issue", "number": issue_number, **details}
852
+ except Exception as e:
853
+ return {"error": str(e)}
854
+ return {}
855
+
856
+ def _save_run_locally(self, result, run_result, runs_dir):
857
+ """Saves run metadata and logs to the local 'runs/' directory."""
858
+ write_json(Path(runs_dir) / "metadata.json", result)
859
+ with open(os.path.join(runs_dir, "stdout.log"), "w") as f:
860
+ f.write(run_result.get("stdout", ""))
861
+ with open(os.path.join(runs_dir, "stderr.log"), "w") as f:
862
+ f.write(run_result.get("stderr", ""))
863
+ click.echo(f"Run results saved to: {runs_dir}")
864
+
865
+ def _trigger_event(self, scenario):
866
+ """Triggers the appropriate GitHub event using the GitHub API."""
867
+ scenario_event = scenario.get_event()
868
+ event_type = scenario_event.get("event_type")
869
+ data = scenario_event.get("data", {})
870
+ actor = scenario_event.get("actor")
871
+ if actor == "attacker" and self.event_gh_client is self.gh_client:
872
+ return False, "Required GitHub actor is unavailable: attacker"
873
+ if actor not in {None, "owner", "attacker"}:
874
+ return False, f"Unknown GitHub actor: {actor}"
875
+ event_client = self.gh_client if actor == "owner" else self.event_gh_client
876
+ repo = event_client.repository
877
+ default_branch = repo.default_branch
878
+
879
+ try:
880
+ if event_type == "issues":
881
+ issue = repo.create_issue(title=data.get("title", "Test Issue"), body=data.get("body", "Test Body"))
882
+ scenario.runtime_state["issue_number"] = issue.number
883
+ return True, None
884
+ elif event_type in ("pull_request", "pull_request_target"):
885
+ head = data.get("head", default_branch)
886
+ pr_client = event_client if actor or ":" in head else self.gh_client
887
+ pr = pr_client.repository.create_pull(
888
+ title=data.get("title", "Test PR"),
889
+ body=data.get("body", "Test Body"),
890
+ head=head,
891
+ base=data.get("base", default_branch),
892
+ )
893
+ scenario.runtime_state["pr_number"] = pr.number
894
+ return True, None
895
+ elif event_type in ["issue_comment", "pull_request_review", "pull_request_review_comment"]:
896
+ if event_type == "pull_request_review_comment":
897
+ return False, "Inline review comments require commit/path/line support; this trigger is unsupported."
898
+ target_number = data.get("number")
899
+ if not target_number:
900
+ prs = repo.get_pulls(state="open", sort="created", direction="desc")
901
+ if prs.totalCount > 0:
902
+ target_number = prs[0].number
903
+
904
+ if target_number:
905
+ if event_type == "pull_request_review":
906
+ scenario.runtime_state["pr_number"] = target_number
907
+ pr = repo.get_pull(target_number)
908
+ pr.create_review(body=data.get("body", "Looks good to me."), event="COMMENT")
909
+ else:
910
+ scenario.runtime_state["issue_number"] = target_number
911
+ issue = repo.get_issue(target_number)
912
+ issue.create_comment(data.get("body", "/review"))
913
+ return True, None
914
+ return False, "Could not find a target PR/Issue for the event."
915
+ elif event_type == "workflow_dispatch":
916
+ workflow = repo.get_workflow(data.get("workflow"))
917
+ if not workflow.create_dispatch(repo.default_branch, data.get("inputs", {})):
918
+ return False, "GitHub rejected workflow dispatch"
919
+ return True, None
920
+ except Exception as e:
921
+ return False, str(e)
922
+
923
+ return False, f"Unknown event type: {event_type}"
924
+
925
+ def _load_scenario(self, scenario_path):
926
+ scenario = load_scenario(scenario_path, self.workspace_dir)
927
+ scenario.runtime_state["repo"] = self.repo_name
928
+ return scenario
929
+
930
+ @retry(
931
+ retry=retry_if_result(lambda res: res is None),
932
+ stop=stop_after_attempt(60),
933
+ wait=wait_exponential(multiplier=1, min=2, max=10),
934
+ retry_error_callback=lambda state: None,
935
+ )
936
+ def _wait_for_run(self, start_time, expected_event=None):
937
+ """Wait for the uniquely matching workflow, excluding pre-trigger runs."""
938
+ candidates = []
939
+ events = {expected_event} if expected_event else None
940
+ if expected_event in {"pull_request", "pull_request_target"}:
941
+ events = {"pull_request", "pull_request_target"}
942
+ workflows = getattr(self, "_workflow_events", None)
943
+ state = getattr(self, "_trial_state", {})
944
+ expected_id = getattr(self, "_expected_run_id", None)
945
+ runs = (
946
+ [self.gh_client.repository.get_workflow_run(expected_id)]
947
+ if expected_id
948
+ else self.gh_client.repository.get_workflow_runs()[:100]
949
+ )
950
+ for run in runs:
951
+ if run.id in getattr(self, "_baseline_run_ids", set()):
952
+ continue
953
+ if run.created_at.timestamp() < start_time:
954
+ continue
955
+ if events and run.event not in events:
956
+ continue
957
+ if workflows is not None:
958
+ path = run.path.split("@", 1)[0]
959
+ if path not in workflows or run.event not in workflows[path]:
960
+ continue
961
+ pr_number = state.get("pr_number")
962
+ if pr_number and run.pull_requests and not any(pr.number == pr_number for pr in run.pull_requests):
963
+ continue
964
+ candidates.append(run)
965
+ if len(candidates) > 1:
966
+ raise RuntimeError("Ambiguous workflow attribution: multiple runs match this trigger")
967
+ if not candidates or candidates[0].status != "completed":
968
+ return None
969
+ return candidates[0].id, candidates[0]
970
+
971
+ def _get_workflow_logs(self, run_id):
972
+ """Retrieves the full logs for a specific workflow run."""
973
+ # Note: Using gh CLI here because pygithub doesn't easily expose full logs in a single call
974
+ stdout, stderr = self.gh_client.run_gh(["run", "view", str(run_id), "--log"], use_repo=True)
975
+ return stdout, stderr
976
+
977
+ def _get_billable_minutes(self, run_id) -> float:
978
+ """Return elapsed runner minutes from the /timing endpoint.
979
+
980
+ On public repos `billable.<OS>.total_ms` is 0 (free minutes). Fall back
981
+ to `run_duration_ms` so the number reflects actual elapsed runner time
982
+ regardless of billing status.
983
+ """
984
+ import json as _json
985
+
986
+ endpoint = f"repos/{self.repo_name}/actions/runs/{run_id}/timing"
987
+ stdout, _ = self.gh_client.run_gh(["api", endpoint])
988
+ try:
989
+ data = _json.loads(stdout)
990
+ except (ValueError, TypeError):
991
+ return 0.0
992
+ billable = data.get("billable") or {}
993
+ total_ms = 0
994
+ for os_data in billable.values():
995
+ if isinstance(os_data, dict):
996
+ total_ms += int(os_data.get("total_ms") or 0)
997
+ if total_ms == 0:
998
+ total_ms = int(data.get("run_duration_ms") or 0)
999
+ return total_ms / 60_000
1000
+
1001
+ def _validate_provider_requirements(self, meta):
1002
+ """Ensures API keys for the specified provider are present in the environment."""
1003
+ provider = meta.get("provider")
1004
+ if not provider:
1005
+ return None
1006
+ provider_keys = {
1007
+ AIProvider.GOOGLE_GEMINI: [
1008
+ "GEMINI_API_KEY",
1009
+ "GEMINI_MODEL",
1010
+ "GEMINI_DEBUG",
1011
+ ],
1012
+ AIProvider.ANTHROPIC_CLAUDE: ["ANTHROPIC_API_KEY"],
1013
+ AIProvider.OPENAI_CODEX: ["OPENAI_API_KEY"],
1014
+ AIProvider.AMAZON_Q: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
1015
+ AIProvider.GITHUB_COPILOT: ["COPILOT_GITHUB_TOKEN"],
1016
+ AIProvider.OPENROUTER: [
1017
+ "OPENROUTER_API_KEY",
1018
+ ],
1019
+ }
1020
+ required_keys = provider_keys.get(provider, [])
1021
+ missing = [key for key in required_keys if not os.environ.get(key)]
1022
+ if missing:
1023
+ return f"Provider '{provider}' requires the following API keys in your local environment: {', '.join(missing)}"
1024
+ return None