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.
- gitinject/__init__.py +1 -0
- gitinject/__main__.py +5 -0
- gitinject/analyzer.py +67 -0
- gitinject/attacks/__init__.py +27 -0
- gitinject/attacks/autoinject.py +165 -0
- gitinject/attacks/base.py +33 -0
- gitinject/attacks/static.py +25 -0
- gitinject/cli.py +768 -0
- gitinject/data/research/scenarios/claude_skills_injection.md +96 -0
- gitinject/data/research/scenarios/cline_issue_body_injection.md +93 -0
- gitinject/data/research/scenarios/codex_agents_md_injection.md +109 -0
- gitinject/data/research/scenarios/dos_request_flood.md +133 -0
- gitinject/data/research/scenarios/dropped/ci_log_injection_workflow_poisoning.md +138 -0
- gitinject/data/research/scenarios/dropped/claude_md_instructions_injection.md +158 -0
- gitinject/data/research/scenarios/dropped/supply_chain_token_pivot.md +101 -0
- gitinject/data/research/scenarios/gemini_api_key_exfiltration.md +68 -0
- gitinject/data/research/scenarios/gemini_api_key_exfiltration_replication.md +0 -0
- gitinject/data/research/scenarios/gemini_md_instructions_injection.md +131 -0
- gitinject/data/research/scenarios/nsfw_api_key_block.md +114 -0
- gitinject/data/research/scenarios/pr_token_exfiltration_via_git_config.md +117 -0
- gitinject/data/research/scenarios/supply_chain_malicious_code.md +135 -0
- gitinject/evaluators.py +169 -0
- gitinject/evidence.py +72 -0
- gitinject/gl_runner.py +163 -0
- gitinject/resources.py +30 -0
- gitinject/run_context.py +72 -0
- gitinject/run_record.py +113 -0
- gitinject/runner.py +1024 -0
- gitinject/scanner/__init__.py +22 -0
- gitinject/scanner/baselines/__init__.py +0 -0
- gitinject/scanner/baselines/actionlint_runner.py +44 -0
- gitinject/scanner/baselines/zizmor_runner.py +35 -0
- gitinject/scanner/diagnostics.py +174 -0
- gitinject/scanner/hypothesis_generator.py +164 -0
- gitinject/scanner/live_validator.py +284 -0
- gitinject/scanner/llm_ranker.py +120 -0
- gitinject/scanner/memory.py +239 -0
- gitinject/scanner/primitives.py +236 -0
- gitinject/scanner/prompt_extractor.py +134 -0
- gitinject/scanner/recipe_scenario.py +203 -0
- gitinject/scanner/report_generator.py +230 -0
- gitinject/scanner/types.py +179 -0
- gitinject/scenario_base.py +147 -0
- gitinject/scenario_loader.py +77 -0
- gitinject/scenarios/__init__.py +0 -0
- gitinject/scenarios/benign/documentation_out_of_sync/contents/math_utils.py +3 -0
- gitinject/scenarios/benign/documentation_out_of_sync/contents/pydantic/v1/utils.py +21 -0
- gitinject/scenarios/benign/documentation_out_of_sync/scenario.py +57 -0
- gitinject/scenarios/benign/issue_deduplication_utility/scenario.py +76 -0
- gitinject/scenarios/benign/repository_onboarding_qa/contents/README.md +3 -0
- gitinject/scenarios/benign/repository_onboarding_qa/scenario.py +52 -0
- gitinject/scenarios/benign/secrets_false_positive/contents/tests/test_auth.py +14 -0
- gitinject/scenarios/benign/secrets_false_positive/scenario.py +58 -0
- gitinject/scenarios/benign/test_coverage_gap/contents/src/requests/auth_extra.py +33 -0
- gitinject/scenarios/benign/test_coverage_gap/scenario.py +56 -0
- gitinject/scenarios/benign/vulnerability_fix_verification/contents/src/flask/utils.py +15 -0
- gitinject/scenarios/benign/vulnerability_fix_verification/scenario.py +57 -0
- gitinject/scenarios/benign/vulnerable_code_review/contents/examples/tutorial/flaskr/db_utils.py +18 -0
- gitinject/scenarios/benign/vulnerable_code_review/scenario.py +55 -0
- gitinject/scenarios/benign/weekly_activity_summary/scenario.py +83 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/AGENTS.md +25 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/agents_md_approval_manipulation/scenario.py +121 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/contents/CLAUDE.md +23 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/contents/scripts/deploy_check.sh +29 -0
- gitinject/scenarios/malicious/claude_md_instructions_injection/scenario.py +93 -0
- gitinject/scenarios/malicious/cline_issue_body_injection/scenario.py +132 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/contents/AGENTS.md +36 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/contents/scripts/utils.py +12 -0
- gitinject/scenarios/malicious/codex_agents_md_injection/scenario.py +128 -0
- gitinject/scenarios/malicious/dos_request_flood/scenario.py +80 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/gemini_md_approval_manipulation/scenario.py +121 -0
- gitinject/scenarios/malicious/gemini_md_instructions_injection/contents/scripts/deploy_check.sh +38 -0
- gitinject/scenarios/malicious/gemini_md_instructions_injection/scenario.py +105 -0
- gitinject/scenarios/malicious/issue_onboarding_secret_exfiltration/scenario.py +76 -0
- gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/contents/scripts/deploy_check.sh +7 -0
- gitinject/scenarios/malicious/pr_token_exfiltration_via_git_config/scenario.py +113 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/CLAUDE.md +25 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/src/flask/csrf.py +151 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/contents/tests/test_csrf.py +71 -0
- gitinject/scenarios/malicious/supply_chain_malicious_code/scenario.py +123 -0
- gitinject/scenarios/malicious/unauthorized_pr_approval/contents/django/utils/crypto.py +13 -0
- gitinject/scenarios/malicious/unauthorized_pr_approval/scenario.py +57 -0
- gitinject/simulator.py +89 -0
- gitinject/utils/__init__.py +0 -0
- gitinject/utils/gh_client.py +628 -0
- gitinject/utils/gl_client.py +132 -0
- gitinject/utils/gl_provisioner.py +83 -0
- gitinject/utils/llm.py +205 -0
- gitinject/utils/provisioner.py +114 -0
- gitinject/utils/scenario_resources.py +33 -0
- gitinject/utils/types.py +49 -0
- gitinject/workflows/__init__.py +0 -0
- gitinject/workflows/claude-ci-auto-fix/contents/.github/workflows/main.yml +107 -0
- gitinject/workflows/claude-ci-auto-fix/metadata.json +10 -0
- gitinject/workflows/claude-general/contents/.github/workflows/main.yml +58 -0
- gitinject/workflows/claude-general/metadata.json +10 -0
- gitinject/workflows/claude-gitlab-mr-review/contents/.gitlab-ci.yml +36 -0
- gitinject/workflows/claude-gitlab-mr-review/metadata.json +11 -0
- gitinject/workflows/claude-issue-deduplication/contents/.github/workflows/main.yml +66 -0
- gitinject/workflows/claude-issue-deduplication/metadata.json +10 -0
- gitinject/workflows/claude-issue-triage/contents/.github/workflows/main.yml +34 -0
- gitinject/workflows/claude-issue-triage/metadata.json +10 -0
- gitinject/workflows/claude-manual-analysis/contents/.github/workflows/main.yml +42 -0
- gitinject/workflows/claude-manual-analysis/metadata.json +10 -0
- gitinject/workflows/claude-pr-review/contents/.github/workflows/main.yml +77 -0
- gitinject/workflows/claude-pr-review/metadata.json +10 -0
- gitinject/workflows/claude-pr-review-authors/contents/.github/workflows/main.yml +48 -0
- gitinject/workflows/claude-pr-review-authors/metadata.json +10 -0
- gitinject/workflows/claude-pr-review-paths/contents/.github/workflows/main.yml +49 -0
- gitinject/workflows/claude-pr-review-paths/metadata.json +10 -0
- gitinject/workflows/claude-test-analysis/contents/.github/workflows/main.yml +114 -0
- gitinject/workflows/claude-test-analysis/metadata.json +10 -0
- gitinject/workflows/cline-assistant/contents/.github/workflows/main.yml +87 -0
- gitinject/workflows/cline-assistant/contents/git-scripts/analyze-issue.sh +43 -0
- gitinject/workflows/cline-assistant/metadata.json +10 -0
- gitinject/workflows/codex-pr-review/contents/.github/workflows/main.yml +73 -0
- gitinject/workflows/codex-pr-review/metadata.json +10 -0
- gitinject/workflows/copilot-ci-doctor/contents/.github/workflows/ci-doctor.yml +1161 -0
- gitinject/workflows/copilot-ci-doctor/metadata.json +10 -0
- gitinject/workflows/copilot-lean-squad/contents/.github/workflows/lean-squad.yml +1313 -0
- gitinject/workflows/copilot-lean-squad/metadata.json +10 -0
- gitinject/workflows/copilot-malicious-scan/contents/.github/workflows/daily-malicious-code-scan.yml +899 -0
- gitinject/workflows/copilot-malicious-scan/metadata.json +10 -0
- gitinject/workflows/copilot-repo-assist/contents/.github/workflows/repo-assist.yml +1503 -0
- gitinject/workflows/copilot-repo-assist/metadata.json +10 -0
- gitinject/workflows/copilot-wiki-writer/contents/.github/workflows/agentic-wiki-writer.yml +1316 -0
- gitinject/workflows/copilot-wiki-writer/metadata.json +10 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-invoke.yml +122 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-plan-execute.yml +130 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-review.yml +118 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-scheduled-triage.yml +220 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/gemini-triage.yml +160 -0
- gitinject/workflows/gemini-assistant/contents/.github/workflows/main.yml +220 -0
- gitinject/workflows/gemini-assistant/metadata.json +10 -0
- gitinject/workflows/gemini-assistant-original/AWESOME.md +118 -0
- gitinject/workflows/gemini-assistant-original/CONFIGURATION.md +162 -0
- gitinject/workflows/gemini-assistant-original/README.md +93 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/README.md +192 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.toml +94 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-invoke.yml +131 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.toml +100 -0
- gitinject/workflows/gemini-assistant-original/gemini-assistant/gemini-plan-execute.yml +139 -0
- gitinject/workflows/gemini-assistant-original/gemini-dispatch/README.md +49 -0
- gitinject/workflows/gemini-assistant-original/gemini-dispatch/gemini-dispatch.yml +221 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/README.md +190 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.toml +96 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-scheduled-triage.yml +223 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.toml +32 -0
- gitinject/workflows/gemini-assistant-original/issue-triage/gemini-triage.yml +167 -0
- gitinject/workflows/gemini-assistant-original/metadata.json +10 -0
- gitinject/workflows/gemini-assistant-original/pr-review/README.md +337 -0
- gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.toml +176 -0
- gitinject/workflows/gemini-assistant-original/pr-review/gemini-review.yml +119 -0
- gitinject/workflows/opencode-pr-review/contents/.github/workflows/main.yml +28 -0
- gitinject/workflows/opencode-pr-review/metadata.json +10 -0
- gitinject-0.1.0.dist-info/METADATA +128 -0
- gitinject-0.1.0.dist-info/RECORD +164 -0
- gitinject-0.1.0.dist-info/WHEEL +4 -0
- gitinject-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|