@murk-security/runner-darwin-x64 0.0.0-stage → 0.7.0
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.
- package/README.md +2 -2
- package/bin/harness +0 -0
- package/package.json +15 -4
- package/runners/basic_harness_run.py +1271 -0
- package/runners/checklists/anonymous.json +24 -0
- package/runners/cve_feed.json +53 -0
- package/runners/prod_env.py +1686 -0
- package/runners/profile.py +307 -0
- package/runners/recon.py +396 -0
- package/runners/remediate.py +429 -0
- package/runners/report.py +365 -0
- package/runners/skills/api/idor-enumeration/SKILL.md +42 -0
- package/runners/skills/exploit/authz-differential/SKILL.md +62 -0
- package/runners/skills/exploit/business-logic/SKILL.md +53 -0
- package/runners/skills/exploit/client-side-exploitation/SKILL.md +63 -0
- package/runners/skills/exploit/client-side-rendering/SKILL.md +57 -0
- package/runners/skills/exploit/credential-chaining/SKILL.md +56 -0
- package/runners/skills/exploit/known-cve-validation/SKILL.md +46 -0
- package/runners/skills/exploit/path-traversal/SKILL.md +44 -0
- package/runners/skills/exploit/reflected-xss/SKILL.md +43 -0
- package/runners/skills/exploit/service-exploitation/SKILL.md +64 -0
- package/runners/skills/exploit/service-probing/SKILL.md +51 -0
- package/runners/skills/exploit/sqli-inference/SKILL.md +46 -0
- package/runners/skills/exploit/ssrf-oob/SKILL.md +48 -0
- package/runners/skills/exploit/state-change-exploitation/SKILL.md +72 -0
- package/runners/skills/exploit/stateful-authz/SKILL.md +50 -0
- package/runners/skills/recon/subdomain-takeover/SKILL.md +45 -0
- package/runners/skills/web/content-discovery/SKILL.md +59 -0
- package/runners/skills/web/graphql-exposure/SKILL.md +40 -0
- package/runners/skills.py +174 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate a code patch for one confirmed finding.
|
|
3
|
+
|
|
4
|
+
The exploit/assessment harness is strictly READ-ONLY, so remediation is a separate,
|
|
5
|
+
isolated step. Rather than a single one-shot model call, this generates a HIGH-QUALITY
|
|
6
|
+
patch via a small pipeline:
|
|
7
|
+
|
|
8
|
+
1. CONTEXT: read the whole target file (not a tiny window) plus a few files it references,
|
|
9
|
+
within a budget, so the model sees enough to produce hunks that actually match.
|
|
10
|
+
2. BEST-OF-N candidates, via diverse strategies: a minimal unified diff (with a repair
|
|
11
|
+
retry that feeds back the exact `git apply` error), a full-file rewrite we diff
|
|
12
|
+
locally (robust against mis-formatted hunks), and a structural-fix variant. Only
|
|
13
|
+
candidates that pass `git apply --check` survive.
|
|
14
|
+
3. REVIEWER pass: a stronger model judges each surviving candidate — does it actually FIX
|
|
15
|
+
the vuln, is it MINIMAL, any REGRESSION risk — and we pick the best-reviewed one.
|
|
16
|
+
|
|
17
|
+
The control plane takes the chosen validated diff and opens a PR via the GitHub App;
|
|
18
|
+
nothing here writes to the repo or runs git beyond the read-only `apply --check`.
|
|
19
|
+
|
|
20
|
+
Importable (the product runner calls generate_patch); model calls are injectable so the
|
|
21
|
+
logic is testable without the network.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import difflib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import urllib.request
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
OPENROUTER_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/") + "/chat/completions"
|
|
35
|
+
MAX_SOURCE_CHARS = 24000 # windowed view of the target file sent in the diff prompt
|
|
36
|
+
MAX_CONTEXT_CHARS = 60000 # total context budget (target view + related files)
|
|
37
|
+
MAX_FULLFILE_CHARS = 40000 # only attempt a full-file rewrite when the file is at/under this
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_key(key_file: str) -> str:
|
|
41
|
+
key = os.environ.get("OPENROUTER_API_KEY")
|
|
42
|
+
if key:
|
|
43
|
+
return key
|
|
44
|
+
try:
|
|
45
|
+
for line in Path(key_file).read_text().splitlines():
|
|
46
|
+
if line.startswith("OPENROUTER_API_KEY="):
|
|
47
|
+
return line.split("=", 1)[1].strip().strip("\"'")
|
|
48
|
+
except OSError:
|
|
49
|
+
pass
|
|
50
|
+
return ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── context ────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def _referenced_files(checkout: str, file: str, text: str) -> list[str]:
|
|
56
|
+
"""Best-effort: repo-local files the target references (Python imports, JS/TS relative
|
|
57
|
+
imports). Gives the model the blast radius, not just the one file. Bounded to a few."""
|
|
58
|
+
root = Path(checkout)
|
|
59
|
+
base = Path(file).parent
|
|
60
|
+
out: list[str] = []
|
|
61
|
+
for m in re.finditer(r"^\s*(?:from|import)\s+([.\w]+)", text, re.M):
|
|
62
|
+
mod = m.group(1).lstrip(".").split(".")[0]
|
|
63
|
+
if not mod:
|
|
64
|
+
continue
|
|
65
|
+
for cand in (base / f"{mod}.py", Path(f"{mod}.py"), base / mod / "__init__.py"):
|
|
66
|
+
if (root / cand).is_file():
|
|
67
|
+
out.append(str(cand))
|
|
68
|
+
break
|
|
69
|
+
for m in re.finditer(r"""(?:from|require\()\s*['"](\.[^'"]+)['"]""", text):
|
|
70
|
+
rel = (base / m.group(1)).as_posix()
|
|
71
|
+
for ext in ("", ".js", ".ts", ".jsx", ".tsx", "/index.js", "/index.ts"):
|
|
72
|
+
if (root / f"{rel}{ext}").is_file():
|
|
73
|
+
out.append(f"{rel}{ext}")
|
|
74
|
+
break
|
|
75
|
+
seen: set[str] = set()
|
|
76
|
+
res: list[str] = []
|
|
77
|
+
for p in out:
|
|
78
|
+
if p != file and p not in seen:
|
|
79
|
+
seen.add(p)
|
|
80
|
+
res.append(p)
|
|
81
|
+
return res[:3]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _gather_context(checkout: str, file: str, line: int | None) -> tuple[dict | None, str | None]:
|
|
85
|
+
"""Read the target file (whole, or windowed around `line` if huge) plus a few referenced
|
|
86
|
+
files, confined to the repo root. Returns (context, error)."""
|
|
87
|
+
root = Path(checkout).resolve()
|
|
88
|
+
target = (root / file).resolve()
|
|
89
|
+
try:
|
|
90
|
+
target.relative_to(root)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None, "source path escapes the repository"
|
|
93
|
+
if not target.is_file():
|
|
94
|
+
return None, f"source file not found in the checkout: {file}"
|
|
95
|
+
full = target.read_text(errors="replace")
|
|
96
|
+
if len(full) > MAX_SOURCE_CHARS and line:
|
|
97
|
+
lines = full.splitlines()
|
|
98
|
+
lo, hi = max(0, line - 200), min(len(lines), line + 200)
|
|
99
|
+
view = "\n".join(lines[lo:hi])[:MAX_SOURCE_CHARS]
|
|
100
|
+
else:
|
|
101
|
+
view = full[:MAX_SOURCE_CHARS]
|
|
102
|
+
related: list[tuple[str, str]] = []
|
|
103
|
+
budget = max(0, MAX_CONTEXT_CHARS - len(view))
|
|
104
|
+
for rel in _referenced_files(checkout, file, full):
|
|
105
|
+
if budget <= 0:
|
|
106
|
+
break
|
|
107
|
+
p = (root / rel).resolve()
|
|
108
|
+
try:
|
|
109
|
+
p.relative_to(root)
|
|
110
|
+
t = p.read_text(errors="replace")
|
|
111
|
+
except (OSError, ValueError):
|
|
112
|
+
continue
|
|
113
|
+
t = t[:budget]
|
|
114
|
+
related.append((rel, t))
|
|
115
|
+
budget -= len(t)
|
|
116
|
+
return {"target_full": full, "target_view": view, "related": related}, None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _related_blob(ctx: dict) -> str:
|
|
120
|
+
return "".join(
|
|
121
|
+
f"\n\nRELATED FILE {p} (context only — do NOT edit it):\n```\n{t}\n```"
|
|
122
|
+
for p, t in ctx.get("related", [])
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── prompts ────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
_DIFF_SYSTEM = (
|
|
129
|
+
"You are a security remediation engineer. You are given a CONFIRMED vulnerability and the "
|
|
130
|
+
"current contents of the source file that contains it. Produce the MINIMAL code change that "
|
|
131
|
+
"fixes the vulnerability without altering unrelated behavior. Output EXACTLY two things and "
|
|
132
|
+
"nothing else:\n"
|
|
133
|
+
"1) a single fenced ```diff block containing a valid unified diff that `git apply -p1` accepts: "
|
|
134
|
+
"it MUST start with `diff --git a/<path> b/<path>`, use `--- a/<path>` / `+++ b/<path>` and `@@` "
|
|
135
|
+
"hunks, with paths relative to the repo root, and its context lines MUST match the file EXACTLY.\n"
|
|
136
|
+
"2) after the block, one line: `RATIONALE: <one sentence on why this fixes it>`.\n"
|
|
137
|
+
"Keep the diff tight; change only what the fix requires."
|
|
138
|
+
)
|
|
139
|
+
_STRUCTURAL_HINT = (
|
|
140
|
+
"\nPrefer a robust STRUCTURAL fix — cryptographic signing/verification, server-side validation, "
|
|
141
|
+
"parameterized/safe APIs, correct escaping, access checks — over blocklists or superficial guards."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _diff_messages(finding: dict, file: str, line: int | None, ctx: dict, variant: int = 0) -> list[dict]:
|
|
146
|
+
system = _DIFF_SYSTEM + (_STRUCTURAL_HINT if variant else "")
|
|
147
|
+
user = (
|
|
148
|
+
f"FINDING: {finding.get('title', '')}\n"
|
|
149
|
+
f"DETAILS: {finding.get('description', '')}\n"
|
|
150
|
+
f"FILE: {file}" + (f" (around line {line})" if line else "") + "\n\n"
|
|
151
|
+
f"CURRENT CONTENTS OF {file}:\n```\n{ctx['target_view']}\n```" + _related_blob(ctx)
|
|
152
|
+
)
|
|
153
|
+
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _fullfile_messages(finding: dict, file: str, full: str) -> list[dict]:
|
|
157
|
+
system = (
|
|
158
|
+
"You are a security remediation engineer. Fix the vulnerability with the MINIMAL change and "
|
|
159
|
+
"do not alter unrelated behavior. Return the COMPLETE corrected contents of the file inside a "
|
|
160
|
+
"single fenced code block, and nothing else except a final line `RATIONALE: <one sentence>`."
|
|
161
|
+
)
|
|
162
|
+
user = (
|
|
163
|
+
f"FINDING: {finding.get('title', '')}\nDETAILS: {finding.get('description', '')}\n"
|
|
164
|
+
f"FILE: {file}\n\nCURRENT CONTENTS:\n```\n{full}\n```"
|
|
165
|
+
)
|
|
166
|
+
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_REVIEW_SYSTEM = (
|
|
170
|
+
"You are a strict, skeptical security code reviewer. You are given a vulnerability, the original "
|
|
171
|
+
"file, and a proposed patch (unified diff). Judge the patch on three axes and respond with ONLY a "
|
|
172
|
+
"JSON object, no prose:\n"
|
|
173
|
+
'{"fixes": true|false, "minimal": true|false, "regression_risk": "none"|"low"|"med"|"high", '
|
|
174
|
+
'"score": <0-100>, "approved": true|false, "notes": "<=2 sentences"}\n'
|
|
175
|
+
"fixes = does it actually close THIS vulnerability (trace the fixed path). minimal = no unrelated "
|
|
176
|
+
"changes. approved = you would merge it. Be honest; a patch that applies but does not fix the vuln "
|
|
177
|
+
"is NOT approved."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _review_messages(finding: dict, file: str, original: str, diff: str) -> list[dict]:
|
|
182
|
+
user = (
|
|
183
|
+
f"VULNERABILITY: {finding.get('title', '')}\nDETAILS: {finding.get('description', '')}\n"
|
|
184
|
+
f"FILE: {file}\n\nORIGINAL FILE:\n```\n{original[:MAX_SOURCE_CHARS]}\n```\n\n"
|
|
185
|
+
f"PROPOSED PATCH:\n```diff\n{diff}\n```"
|
|
186
|
+
)
|
|
187
|
+
return [{"role": "system", "content": _REVIEW_SYSTEM}, {"role": "user", "content": user}]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── model + diff plumbing ────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def call_model(key: str, model: str, messages: list[dict], timeout: int = 150) -> str:
|
|
193
|
+
body = json.dumps({"model": model, "messages": messages, "temperature": 0}).encode()
|
|
194
|
+
req = urllib.request.Request(
|
|
195
|
+
OPENROUTER_URL, data=body, method="POST",
|
|
196
|
+
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
197
|
+
)
|
|
198
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
199
|
+
data = json.loads(resp.read().decode("utf-8", "replace"))
|
|
200
|
+
return data["choices"][0]["message"]["content"]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def extract_diff(text: str) -> str:
|
|
204
|
+
"""Pull a unified diff out of the model output: prefer a fenced block, then trim to the
|
|
205
|
+
first real diff marker so surrounding prose never reaches `git apply`."""
|
|
206
|
+
m = re.search(r"```(?:diff|patch)?\s*\n(.*?)```", text or "", re.S)
|
|
207
|
+
body = m.group(1) if m else (text or "")
|
|
208
|
+
idx = None
|
|
209
|
+
for marker in ("diff --git", "--- a/", "--- "):
|
|
210
|
+
i = body.find(marker)
|
|
211
|
+
if i != -1 and (idx is None or i < idx):
|
|
212
|
+
idx = i
|
|
213
|
+
if idx is None:
|
|
214
|
+
return ""
|
|
215
|
+
return body[idx:].rstrip() + "\n"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def extract_rationale(text: str) -> str:
|
|
219
|
+
m = re.search(r"RATIONALE:\s*(.+)", text or "")
|
|
220
|
+
return m.group(1).strip()[:500] if m else ""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _extract_code_block(text: str) -> str:
|
|
224
|
+
"""Pull FULL-FILE contents out of a fenced block for the rewrite strategy. Skips
|
|
225
|
+
```diff/```patch fences (those are diffs, not file contents)."""
|
|
226
|
+
for m in re.finditer(r"```([A-Za-z0-9_+-]*)\s*\n(.*?)```", text or "", re.S):
|
|
227
|
+
if m.group(1).lower() in ("diff", "patch"):
|
|
228
|
+
continue
|
|
229
|
+
return m.group(2)
|
|
230
|
+
return ""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _unified_diff(file: str, old: str, new: str) -> str:
|
|
234
|
+
"""Build a git-apply-able unified diff between two full file contents, computed locally
|
|
235
|
+
(robust against a model mis-formatting hunk headers)."""
|
|
236
|
+
a = old.splitlines(keepends=True)
|
|
237
|
+
b = new.splitlines(keepends=True)
|
|
238
|
+
body = "".join(difflib.unified_diff(a, b, fromfile=f"a/{file}", tofile=f"b/{file}", n=3))
|
|
239
|
+
if not body.strip():
|
|
240
|
+
return ""
|
|
241
|
+
return f"diff --git a/{file} b/{file}\n{body}"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def validate_diff(checkout: str, diff: str) -> tuple[bool, str | None]:
|
|
245
|
+
"""Does the diff apply cleanly to the checkout? Read-only (`--check`): applies nothing."""
|
|
246
|
+
if not diff.strip():
|
|
247
|
+
return False, "no diff produced"
|
|
248
|
+
try:
|
|
249
|
+
p = subprocess.run(
|
|
250
|
+
["git", "apply", "--check", "-p1", "-"], input=diff, text=True,
|
|
251
|
+
cwd=checkout, capture_output=True, timeout=30,
|
|
252
|
+
)
|
|
253
|
+
return (p.returncode == 0), (p.stderr.strip() or None)
|
|
254
|
+
except Exception as e: # noqa: BLE001
|
|
255
|
+
return False, f"git apply --check failed to run: {e}"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ── candidates ─────────────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
def _diff_candidate(checkout: str, finding: dict, file: str, line: int | None, ctx: dict,
|
|
261
|
+
key: str, model: str, model_fn, max_attempts: int, variant: int) -> dict | None:
|
|
262
|
+
"""One diff-strategy candidate, with a repair retry that feeds back the git-apply error.
|
|
263
|
+
Returns the applying candidate if found, else the last non-applying one, else None."""
|
|
264
|
+
messages = _diff_messages(finding, file, line, ctx, variant)
|
|
265
|
+
last: dict | None = None
|
|
266
|
+
for attempt in range(max(1, max_attempts)):
|
|
267
|
+
try:
|
|
268
|
+
out = model_fn(key, model, messages)
|
|
269
|
+
except Exception: # noqa: BLE001
|
|
270
|
+
return last
|
|
271
|
+
diff = extract_diff(out)
|
|
272
|
+
if diff:
|
|
273
|
+
applies, aerr = validate_diff(checkout, diff)
|
|
274
|
+
cand = {"diff": diff, "rationale": extract_rationale(out), "applies": applies,
|
|
275
|
+
"strategy": "diff", "error": None if applies else (aerr or "did not apply cleanly")}
|
|
276
|
+
if applies:
|
|
277
|
+
return cand
|
|
278
|
+
last = cand
|
|
279
|
+
feedback = (f"That diff did not apply (`git apply --check`: {aerr}). Produce a corrected "
|
|
280
|
+
"unified diff whose @@ hunk headers and context lines EXACTLY match the file "
|
|
281
|
+
"shown above. Output only the ```diff block and the RATIONALE line.")
|
|
282
|
+
else:
|
|
283
|
+
feedback = ("You did not return a unified diff. Output only a single ```diff block that "
|
|
284
|
+
"`git apply -p1` accepts, then the RATIONALE line.")
|
|
285
|
+
if attempt + 1 < max(1, max_attempts):
|
|
286
|
+
messages = messages + [{"role": "assistant", "content": out},
|
|
287
|
+
{"role": "user", "content": feedback}]
|
|
288
|
+
return last
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _fullfile_candidate(checkout: str, finding: dict, file: str, full: str,
|
|
292
|
+
key: str, model: str, model_fn) -> dict | None:
|
|
293
|
+
"""Full-file-rewrite strategy: ask for the whole corrected file, diff it ourselves. Robust
|
|
294
|
+
against hunk-format mistakes. Only for files small enough to rewrite without truncation."""
|
|
295
|
+
if not full or len(full) > MAX_FULLFILE_CHARS:
|
|
296
|
+
return None
|
|
297
|
+
try:
|
|
298
|
+
out = model_fn(key, model, _fullfile_messages(finding, file, full))
|
|
299
|
+
except Exception: # noqa: BLE001
|
|
300
|
+
return None
|
|
301
|
+
new = _extract_code_block(out)
|
|
302
|
+
if not new or new.strip() == full.strip():
|
|
303
|
+
return None
|
|
304
|
+
if re.match(r"\s*(diff --git|--- |\+\+\+ |@@ )", new):
|
|
305
|
+
return None # a diff slipped through, not a file rewrite
|
|
306
|
+
diff = _unified_diff(file, full, new)
|
|
307
|
+
if not diff:
|
|
308
|
+
return None
|
|
309
|
+
applies, aerr = validate_diff(checkout, diff)
|
|
310
|
+
return {"diff": diff, "rationale": extract_rationale(out), "applies": applies,
|
|
311
|
+
"strategy": "full-file", "error": None if applies else (aerr or "did not apply cleanly")}
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def generate_candidates(checkout: str, finding: dict, file: str, line: int | None, ctx: dict,
|
|
315
|
+
key: str, model: str, model_fn, n: int, max_attempts: int) -> tuple[list[dict], list[dict]]:
|
|
316
|
+
"""Produce up to `n` distinct candidate patches via diverse strategies. Returns
|
|
317
|
+
(applying_unique, all_produced)."""
|
|
318
|
+
cands: list[dict] = []
|
|
319
|
+
seen: set[str] = set()
|
|
320
|
+
|
|
321
|
+
def add(c: dict | None) -> None:
|
|
322
|
+
if c and c.get("diff") and c["diff"] not in seen:
|
|
323
|
+
seen.add(c["diff"])
|
|
324
|
+
cands.append(c)
|
|
325
|
+
|
|
326
|
+
add(_diff_candidate(checkout, finding, file, line, ctx, key, model, model_fn, max_attempts, variant=0))
|
|
327
|
+
add(_fullfile_candidate(checkout, finding, file, ctx["target_full"], key, model, model_fn))
|
|
328
|
+
variant = 1
|
|
329
|
+
while len([c for c in cands if c.get("applies")]) < n and variant <= 2:
|
|
330
|
+
add(_diff_candidate(checkout, finding, file, line, ctx, key, model, model_fn, 1, variant=variant))
|
|
331
|
+
variant += 1
|
|
332
|
+
applying = [c for c in cands if c.get("applies")]
|
|
333
|
+
return applying, cands
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── reviewer ──────────────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
def _parse_review(text: str) -> dict | None:
|
|
339
|
+
m = re.search(r"\{.*\}", text or "", re.S)
|
|
340
|
+
if not m:
|
|
341
|
+
return None
|
|
342
|
+
try:
|
|
343
|
+
v = json.loads(m.group(0))
|
|
344
|
+
except (json.JSONDecodeError, TypeError):
|
|
345
|
+
return None
|
|
346
|
+
if not isinstance(v, dict):
|
|
347
|
+
return None
|
|
348
|
+
score = v.get("score", 0)
|
|
349
|
+
try:
|
|
350
|
+
score = max(0, min(100, int(score)))
|
|
351
|
+
except (ValueError, TypeError):
|
|
352
|
+
score = 0
|
|
353
|
+
return {
|
|
354
|
+
"fixes": bool(v.get("fixes")),
|
|
355
|
+
"minimal": bool(v.get("minimal", True)),
|
|
356
|
+
"regression_risk": str(v.get("regression_risk", "unknown"))[:16],
|
|
357
|
+
"score": score,
|
|
358
|
+
"approved": bool(v.get("approved", v.get("fixes"))),
|
|
359
|
+
"notes": str(v.get("notes", ""))[:400],
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def review_patch(finding: dict, file: str, original: str, diff: str,
|
|
364
|
+
key: str, reviewer_model: str, reviewer_fn) -> dict | None:
|
|
365
|
+
try:
|
|
366
|
+
out = reviewer_fn(key, reviewer_model, _review_messages(finding, file, original, diff))
|
|
367
|
+
except Exception: # noqa: BLE001
|
|
368
|
+
return None
|
|
369
|
+
return _parse_review(out)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ── orchestration ────────────────────────────────────────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
def _failed(error: str, file: str | None = None) -> dict:
|
|
375
|
+
r = {"status": "failed", "error": error}
|
|
376
|
+
if file:
|
|
377
|
+
r["targetFile"] = file
|
|
378
|
+
return r
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def generate_patch(checkout: str, finding: dict, key: str, model: str, model_fn=call_model,
|
|
382
|
+
reviewer_model: str | None = None, reviewer_fn=None,
|
|
383
|
+
candidates: int = 3, max_attempts: int = 2) -> dict:
|
|
384
|
+
"""Produce {diff, rationale, targetFile, applies, status, ...} for one finding.
|
|
385
|
+
|
|
386
|
+
Pipeline: gather context -> generate best-of-N candidates (diverse strategies, each
|
|
387
|
+
validated with `git apply --check`) -> optional reviewer pass picks the best. status is
|
|
388
|
+
'proposed' when a diff was produced (whether or not it applies), 'failed' when none was.
|
|
389
|
+
When a reviewer runs, the result carries `review` and `verified`."""
|
|
390
|
+
src = finding.get("source") or {}
|
|
391
|
+
file = src.get("file")
|
|
392
|
+
if not file:
|
|
393
|
+
return _failed("finding has no source file to patch")
|
|
394
|
+
ctx, err = _gather_context(checkout, file, src.get("line"))
|
|
395
|
+
if err or ctx is None:
|
|
396
|
+
return _failed(err or "could not read source", file)
|
|
397
|
+
|
|
398
|
+
applying, produced = generate_candidates(
|
|
399
|
+
checkout, finding, file, src.get("line"), ctx, key, model, model_fn, candidates, max_attempts)
|
|
400
|
+
|
|
401
|
+
if not applying:
|
|
402
|
+
last = next((c for c in produced if c.get("diff")), None)
|
|
403
|
+
if last:
|
|
404
|
+
return {"diff": last["diff"], "rationale": last.get("rationale", ""), "targetFile": file,
|
|
405
|
+
"applies": False, "status": "proposed",
|
|
406
|
+
"error": last.get("error") or "patch did not apply cleanly after retries"}
|
|
407
|
+
return _failed("model did not return a unified diff", file)
|
|
408
|
+
|
|
409
|
+
reviewer_fn = reviewer_fn or model_fn
|
|
410
|
+
chosen = applying[0]
|
|
411
|
+
if reviewer_model:
|
|
412
|
+
for c in applying:
|
|
413
|
+
c["review"] = review_patch(finding, file, ctx["target_full"], c["diff"], key, reviewer_model, reviewer_fn)
|
|
414
|
+
reviewed = [c for c in applying if c.get("review")]
|
|
415
|
+
if reviewed:
|
|
416
|
+
approved = [c for c in reviewed if c["review"]["approved"]]
|
|
417
|
+
pool = approved or reviewed
|
|
418
|
+
chosen = max(pool, key=lambda c: c["review"]["score"])
|
|
419
|
+
|
|
420
|
+
result = {
|
|
421
|
+
"diff": chosen["diff"], "rationale": chosen.get("rationale", ""), "targetFile": file,
|
|
422
|
+
"applies": True, "status": "proposed",
|
|
423
|
+
"strategy": chosen.get("strategy"), "candidates": len(applying),
|
|
424
|
+
}
|
|
425
|
+
review = chosen.get("review")
|
|
426
|
+
if review is not None:
|
|
427
|
+
result["review"] = review
|
|
428
|
+
result["verified"] = bool(review["approved"])
|
|
429
|
+
return result
|