README / ghidra-re-loop /objective_verify.py
Nekochu's picture
Add skill ghidra-re-loop
ae4930d verified
Raw
History Blame Contribute Delete
5.81 kB
#!/usr/bin/env python3
"""Non-LLM structural verifier — adapted port of auto-re-agent's
verification/objective.py (verify_candidate) + utils/text.py.
Purpose: an INDEPENDENT gate that rejects a sub-agent's reconstructed/reversed
function when it structurally diverges from the Ghidra decompile. Catches a
sub-agent that hand-waved, read the wrong function, or dropped branches/calls.
It never relies on an LLM. It only fails on STRONG mismatches; otherwise PASS or
UNKNOWN (insufficient data). Pair it with an LLM checker sub-agent — accept a
candidate only when checker=PASS AND objective!=FAIL (mirrors run_fix_loop).
Usage:
python objective_verify.py --candidate cand.c --decompile golden.c
python objective_verify.py --candidate cand.c --decompile golden.c \
--callees 9 --asm-calls 11 --call-tol 3 --flow-tol 2
--candidate file with the sub-agent's reconstructed C/pseudo-C function
--decompile the ground-truth Ghidra decompile of the SAME function
(extract it from all_decomp.c by its "/* ===== name @ VA ===== */" banner)
--callees optional: true callee count if you have it (else counted from decompile)
--asm-calls optional: call instruction count from disassembly (capstone)
Exit code: 0 PASS, 1 FAIL, 2 UNKNOWN.
"""
from __future__ import annotations
import argparse
import re
import sys
COMMENT_BLOCK_RE = re.compile(r"/\*.*?\*/", re.S)
COMMENT_LINE_RE = re.compile(r"//.*")
TOKEN_CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_:]*)\s*\(")
CONTROL_FLOW_RE = re.compile(r"\b(if|for|while|switch|do|goto)\b")
# Tokens that look like calls but are language constructs, not callees.
CPP_KEYWORDS = {
"if", "for", "while", "switch", "return", "sizeof", "alignof", "decltype",
"static_cast", "reinterpret_cast", "const_cast", "dynamic_cast", "catch",
"new", "delete",
}
def strip_comments(text: str) -> str:
return COMMENT_LINE_RE.sub("", COMMENT_BLOCK_RE.sub("", text))
def count_calls(body_no_comments: str) -> int:
total = 0
for m in TOKEN_CALL_RE.finditer(body_no_comments):
tok = m.group(1)
if tok in CPP_KEYWORDS:
continue
if tok.endswith("::operator") or tok == "operator":
continue
total += 1
return total
def count_control_flow(body_no_comments: str) -> int:
return len(CONTROL_FLOW_RE.findall(body_no_comments))
def extract_body(text: str) -> str:
"""Return the outermost { ... } block, or the whole text if none found."""
open_brace = text.find("{")
close_brace = text.rfind("}")
if open_brace == -1 or close_brace == -1 or close_brace <= open_brace:
return text
return text[open_brace:close_brace + 1]
def verify(candidate: str, decompile: str, callees: int | None,
asm_calls: int | None, call_tol: int, flow_tol: int):
"""Port of verify_candidate. Returns (verdict, summary, findings)."""
if not candidate.strip():
return "FAIL", "No candidate code produced", ["Candidate code is empty"]
cand_body = strip_comments(extract_body(candidate))
cand_calls = count_calls(cand_body)
cand_flow = count_control_flow(cand_body)
dec_body = strip_comments(extract_body(decompile))
dec_calls = count_calls(dec_body)
dec_flow = count_control_flow(dec_body)
findings: list[str] = []
checks_run = 0
# Callee/call-count check: FAIL only when candidate has FEWER calls than the
# ground truth by more than tolerance (missing logic), never the reverse.
ref_callees = callees if callees is not None else dec_calls
if ref_callees:
checks_run += 1
if (ref_callees - cand_calls) >= call_tol and cand_calls < ref_callees:
findings.append(
f"Call-count mismatch: ground truth ~{ref_callees} calls, "
f"candidate has {cand_calls}")
# Control-flow check (only meaningful when the decompile actually branches).
if dec_flow >= 2:
checks_run += 1
if (dec_flow - cand_flow) >= flow_tol and cand_flow < dec_flow:
findings.append(
f"Control-flow mismatch: decompile has {dec_flow} branches/loops, "
f"candidate has {cand_flow}")
# Optional independent asm cross-check.
if asm_calls is not None:
checks_run += 1
if (asm_calls - cand_calls) >= call_tol and cand_calls < asm_calls:
findings.append(
f"ASM call mismatch: disassembly has {asm_calls} calls, "
f"candidate has {cand_calls}")
if findings:
return "FAIL", "Objective verifier found structural mismatches", findings
if checks_run == 0:
return "UNKNOWN", "Insufficient structural data to judge", []
return "PASS", "No structural mismatches found", []
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--candidate", required=True)
ap.add_argument("--decompile", required=True)
ap.add_argument("--callees", type=int, default=None)
ap.add_argument("--asm-calls", type=int, default=None)
ap.add_argument("--call-tol", type=int, default=3)
ap.add_argument("--flow-tol", type=int, default=2)
a = ap.parse_args()
with open(a.candidate, encoding="utf-8", errors="ignore") as f:
cand = f.read()
with open(a.decompile, encoding="utf-8", errors="ignore") as f:
dec = f.read()
verdict, summary, findings = verify(
cand, dec, a.callees, a.asm_calls, a.call_tol, a.flow_tol)
print(f"OBJECTIVE: {verdict}")
print(f"SUMMARY: {summary}")
for fnd in findings:
print(f" - {fnd}")
return {"PASS": 0, "FAIL": 1, "UNKNOWN": 2}[verdict]
if __name__ == "__main__":
sys.exit(main())