| |
| """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") |
|
|
| |
| 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 |
|
|
| |
| |
| 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}") |
|
|
| |
| 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}") |
|
|
| |
| 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()) |
|
|