mirror of
https://github.com/mytechnotalent/rea.git
synced 2026-11-07 05:56:22 -10:00
Release v5.4.0: nation-state report redesign (LLM-authored intelligence assessment with evidence appendix, always-on citation verification, adversarial reviewers + adjudicator), ARM decompiler accuracy fixes, and MCP disasm/regions tools
This commit is contained in:
1 parent
a04127f8d3
commit
d1937c80a0
11 files changed
+1075
-246
No files matched your search
@@ -70,5 +70,13 @@ Only generated build-time output under `OUT_DIR` is exempt from the custom check
|
||||
|
||||
<br>
|
||||
|
||||
# README and release notes
|
||||
The README contains exactly one release-notes section: `# New in vX.Y.Z` for the current version
|
||||
only. On each release, replace that section in place and update the H1 title version. Never append a
|
||||
new `# New in` section and never keep prior ones — a README with two or more `# New in v` headings
|
||||
is a defect. Prior release notes belong only in GitHub Releases and `PLAN.md`.
|
||||
|
||||
<br>
|
||||
|
||||
# License
|
||||
[MIT License](https://github.com/mytechnotalent/rea/blob/main/LICENSE)
|
||||
Generated
+6
-6
@@ -1890,7 +1890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rea"
|
||||
version = "5.3.0"
|
||||
version = "5.4.0"
|
||||
dependencies = [
|
||||
"rea-engine",
|
||||
"rea-report",
|
||||
@@ -1900,7 +1900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rea-engine"
|
||||
version = "5.3.0"
|
||||
version = "5.4.0"
|
||||
dependencies = [
|
||||
"goblin",
|
||||
"iced-x86",
|
||||
@@ -1917,7 +1917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rea-report"
|
||||
version = "5.3.0"
|
||||
version = "5.4.0"
|
||||
dependencies = [
|
||||
"typst",
|
||||
"typst-assets",
|
||||
@@ -1927,7 +1927,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rea-sleigh"
|
||||
version = "5.3.0"
|
||||
version = "5.4.0"
|
||||
dependencies = [
|
||||
"include_dir",
|
||||
"wazabin-sleigh",
|
||||
@@ -2000,7 +2000,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "reverse-engineering"
|
||||
version = "5.3.0"
|
||||
version = "5.4.0"
|
||||
dependencies = [
|
||||
"rea",
|
||||
"rea-engine",
|
||||
@@ -3583,7 +3583,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "5.3.0"
|
||||
version = "5.4.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/rea-sleigh", "crates/rea", "crates/rea-engine", "crates/rea-report", "crates/reverse-engineering", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "5.3.0"
|
||||
version = "5.4.0"
|
||||
edition = "2021"
|
||||
authors = ["Kevin Thomas"]
|
||||
license = "MIT"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Project:** Reverse Engineering Assistant (REA)
|
||||
**Repository:** `mytechnotalent/rea`
|
||||
**Current release:** v5.3.0
|
||||
**Current release:** v5.4.0
|
||||
**Document owner:** Kevin Thomas
|
||||
|
||||
---
|
||||
@@ -46,6 +46,45 @@ agents (OpenCode, Claude Code, Cursor, Windsurf).
|
||||
| v5.2.0 | Autonomous analyst rebuilt: native tool calling, 90-turn loop, LLM-authored grounded report, entropy regions + 8051 tools, DeepSeek V4.1 Flash default. |
|
||||
| v5.2.1 | Fix decompiler infinite loop (self/cyclic copy map, oscillating fixpoints); bound the analyst loop with a wall-clock budget and forced final report. |
|
||||
| v5.3.0 | Firmware container decoding + load-base re-basing (`REA_LOAD_BASE`), analyst dedupe/no-progress stop, full-string grounding, instruction-annotation accuracy fixes. |
|
||||
| v5.3.1 | Fix silent AI failure (missing `max_tokens` caused HTTP 402); add `disasm` tool (function listing + call graph + strings); richer evidence; behavior-forcing prompt; surface API errors; automatic model failover. |
|
||||
| v5.4.0 | Nation-state report redesign: LLM authors the full intelligence assessment (BLUF, capability/behavior/function analysis, attribution), deterministic findings become an Evidence Appendix, always-on citation verification plus adversarial reviewers and an adjudicator (`REA_VERIFY`). |
|
||||
|
||||
---
|
||||
|
||||
## 3a. v5.4.0 — Nation-State Report Redesign (in progress)
|
||||
|
||||
**Goal:** the report is the product. An LLM authors a nation-state-grade intelligence assessment
|
||||
end to end; the deterministic engine is the evidence appendix. No unproven claim enters the report.
|
||||
|
||||
**Architecture — Author, Adversarial Reviewers, Adjudicator:**
|
||||
|
||||
1. **Author** — the analyst loop writes the full narrative with inline citations (addresses,
|
||||
strings, offsets) and a confidence (High/Medium/Low) per non-obvious claim.
|
||||
2. **Deterministic verifier (always on)** — every cited address and quoted string must exist in the
|
||||
evidence corpus (dossier + strings + all tool observations); anything absent is flagged.
|
||||
3. **Adversarial reviewers (LLM, opt-in)** — independent passes attempt to falsify each claim
|
||||
against the evidence and report contradictions and unsupported statements.
|
||||
4. **Adjudicator (LLM, opt-in)** — resolves verdicts; only proven claims survive into the final
|
||||
report, and everything rejected is listed in a verification appendix.
|
||||
|
||||
**Report structure (LLM-authored body, deterministic evidence appendix):**
|
||||
|
||||
1. BLUF — Bottom Line Up Front
|
||||
2. Target Identification & Provenance
|
||||
3. Capability Assessment (ATT&CK/MBC + crypto + network, each with confidence)
|
||||
4. Behavioral Analysis (end-to-end, plain English)
|
||||
5. Function Analysis (per key function: real summary, call graph, constants, pseudocode)
|
||||
6. Network, C2 & IOCs
|
||||
7. Secrets & Cryptography
|
||||
8. Vulnerabilities & Exploitation
|
||||
9. Anti-Analysis & Evasion
|
||||
10. Attribution & Tooling
|
||||
11. Assessment Confidence & Limitations
|
||||
12. Evidence Appendix (function inventory, call graph, strings, sections, imports)
|
||||
|
||||
**Status:** report assembly now leads with the authored narrative and relegates deterministic
|
||||
findings to an Evidence Appendix; the analyst prompt emits the structure above; deterministic
|
||||
verification covers addresses and strings. Adversarial reviewers and the adjudicator are next.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<br>
|
||||
|
||||
# Reverse Engineering Assistant (REA) v5.3.0
|
||||
# Reverse Engineering Assistant (REA) v5.4.0
|
||||
|
||||
Autonomous Reverse Engineering Framework
|
||||
|
||||
@@ -18,38 +18,16 @@ Writes `re-suspect.md`, `re-suspect.pdf`, and `re-suspect.json` next to the targ
|
||||
|
||||
<br>
|
||||
|
||||
# New in v5.3.0
|
||||
# New in v5.4.0
|
||||
|
||||
REA v5.3.0 adds firmware-container awareness and sharpens analysis quality:
|
||||
REA v5.4.0 redesigns the report into a nation-state-grade intelligence assessment, authored end to end by the LLM and verified before anything ships.
|
||||
|
||||
- **Firmware Container Decoding.** Detects multi-record flash-container headers and reports the entry point, declared size, descriptor records, and the deduced runtime load base in a new report section, so firmware is analyzed at its true address instead of a flat offset map.
|
||||
- **Correct Raw-Image Base.** Raw firmware handed to the provider (Binary Ninja) is wrapped at its detected load base, so addresses, cross-references, and symbols resolve at the real runtime address. Override with `REA_LOAD_BASE`.
|
||||
- **Faster Analyst Loop.** The autonomous analyst skips duplicate tool calls and stops after repeated no-progress turns, cutting wasted turns and cost.
|
||||
- **Stronger Grounding.** The grounding validator now checks quotations against the full extracted-string set, not just the dossier, reducing false positives.
|
||||
- **Accurate Instructions.** Fixed register corruption in immediate formatting (`r12` no longer becomes `r0xc`) and corrected branch classification (`beq` is a conditional branch with its condition, not "unconditional"); block stores/loads (`stm`/`ldm`) are described as stack-frame operations.
|
||||
|
||||
# New in v5.2.1
|
||||
|
||||
REA v5.2.1 is a reliability release that fixes a decompiler hang and hardens the autonomous analyst:
|
||||
|
||||
- **Decompiler Hang Fixed.** Copy propagation could map a register to itself (`mov r0, r0`), and the dominator and copy-propagation fixpoints could oscillate on cyclic control flow, hanging `rea decompile` and the one-shot on some ARM Cortex-M targets. Self/cyclic mappings are now rejected and both fixpoints are iteration-bounded.
|
||||
- **Bounded Analyst Loop.** The agent loop now enforces a wall-clock budget (default 420s, `REA_AGENT_BUDGET_SECS`), forces a final report when the budget expires (`tool_choice: none`), caps each observation, and applies a per-request network timeout so a one-shot always terminates with a report.
|
||||
- **Live Progress.** The analyst prints per-turn and per-tool progress so long investigations are visible.
|
||||
|
||||
# New in v5.2.0
|
||||
|
||||
REA v5.2.0 rebuilds the autonomous analyst into a true tool-calling author, so the AI actually investigates the target instead of summarizing a static dossier:
|
||||
|
||||
- **Native Tool Calling.** The analyst uses OpenRouter function/tool calling (JSON schemas) instead of a brittle text protocol, eliminating mis-parsed actions and hallucinated free-writing.
|
||||
- **90-Turn Investigation.** The agent loop runs up to 90 tool turns (up from 4), so it decompiles, emulates, and reads its way to an answer.
|
||||
- **The LLM Authors the Report.** The model is the report's author; deterministic engine sections feed it as evidence, and the authored assessment leads the report.
|
||||
- **Grounding Validator.** Every quoted string in the final report is checked against the evidence dossier and all tool observations; unverified quotations are flagged rather than shipped as fact.
|
||||
- **Capable Default Model.** Defaults to `deepseek/deepseek-v4.1-flash`; override with `OPENROUTER_MODEL`.
|
||||
- **Stronger Engine Tools.** The loop gains entropy `regions` segmentation and 8051 (`MCS-51`) disassembly. Every tool is pure-Rust and works one-shot with no external tools, servers, or licenses required.
|
||||
|
||||
Retained from v5.1.0: DWARF/PDB symbols, YARA-subset matching, JVM `.class`, Android `resources.arsc`, source-map VLQ, WASM code bodies, crypto expansion, artifact report sections, and the 31-tool MCP server.
|
||||
|
||||
<br>
|
||||
- **Report authored by the analyst.** The report now leads with the AI narrative: BLUF, Target Identification & Provenance, Capability Assessment (with confidence), Behavioral Analysis, Function Analysis, Network/C2/IOCs, Secrets & Cryptography, Vulnerabilities, Anti-Analysis, Attribution, and Confidence & Limitations. The deterministic engine findings move to an **Evidence Appendix**.
|
||||
- **Verification (always on).** Every cited address and quoted string must exist in the engine evidence; proven reports carry a `[Verification]` banner and unproven citations are flagged.
|
||||
- **Adversarial review + adjudication (`REA_VERIFY=1`).** Independent reviewers attempt to falsify each claim against the evidence, and an adjudicator rewrites the report to remove anything unsupported. Tune with `REA_REVIEWERS`.
|
||||
- **Decompiler accuracy.** Fixed ARM memory-operand handling: `ldrb r3,[r3,#0]` decompiles to `r3 = *(r3)` (not `r3 = [r3`), `str` correctly orders memory as the destination, and `.w`/`.n` width-suffixed instructions lift.
|
||||
- **Presentation.** The Function Analysis section summarizes each ranked function in plain English with its call graph; raw instruction streams live in the `.asm` artifact.
|
||||
- **MCP parity.** The MCP server adds `disasm_function` and `classify_regions` (33 tools), and `generate_dossier` produces the same redesigned report.
|
||||
|
||||
# Features
|
||||
|
||||
|
||||
@@ -233,8 +233,25 @@ pub fn lift(line: &InstructionLine, arch: Arch) -> Line {
|
||||
fn split_asm(text: &str) -> (String, &str) {
|
||||
let trimmed = text.trim();
|
||||
match trimmed.split_once(char::is_whitespace) {
|
||||
Some((m, rest)) => (m.to_ascii_lowercase(), rest.trim()),
|
||||
None => (trimmed.to_ascii_lowercase(), ""),
|
||||
Some((m, rest)) => (base_mnemonic(m), rest.trim()),
|
||||
None => (base_mnemonic(trimmed), ""),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lowercases a mnemonic and drops an ARM width suffix.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - Raw mnemonic text.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The lowercased base mnemonic.
|
||||
fn base_mnemonic(text: &str) -> String {
|
||||
let lower = text.to_ascii_lowercase();
|
||||
match lower.strip_suffix(".w").or_else(|| lower.strip_suffix(".n")) {
|
||||
Some(base) => base.to_string(),
|
||||
None => lower,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,11 +448,30 @@ fn assign_pair(mnemonic: &str, dst: &str, src: &str, extra: Option<&str>) -> Ins
|
||||
return Inst::Assign { dst: parse_expr(dst), src: address_of(src) };
|
||||
}
|
||||
if STORES.contains(&mnemonic) {
|
||||
return Inst::Store { addr: parse_expr(dst), src: parse_expr(src) };
|
||||
return store_from(dst, src);
|
||||
}
|
||||
value_pair(mnemonic, dst, src, extra)
|
||||
}
|
||||
|
||||
/// Lifts a store, ordering the memory and value operands correctly.
|
||||
///
|
||||
/// Handles both x86 `store [mem], reg` and ARM/RISC-V `store reg, [mem]`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `dst` - First operand text.
|
||||
/// * `src` - Second operand text.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The store instruction.
|
||||
fn store_from(dst: &str, src: &str) -> Inst {
|
||||
if is_memory(dst) {
|
||||
return Inst::Store { addr: address_of(dst), src: parse_expr(src) };
|
||||
}
|
||||
Inst::Store { addr: address_of(src), src: parse_expr(dst) }
|
||||
}
|
||||
|
||||
/// Lifts a value-producing two- or three-operand instruction.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -535,7 +571,7 @@ fn arith_op(mnemonic: &str) -> Option<&'static str> {
|
||||
fn parse_expr(text: &str) -> Expr {
|
||||
let trimmed = strip_size(text.trim()).trim_start_matches('#');
|
||||
if let Some(inner) = bracketed(trimmed) {
|
||||
return Expr::Mem(Box::new(parse_sum(inner)));
|
||||
return Expr::Mem(Box::new(drop_zero(parse_sum(inner))));
|
||||
}
|
||||
if let Some(inner) = parenthesized(trimmed) {
|
||||
return Expr::Mem(Box::new(inner));
|
||||
@@ -543,6 +579,22 @@ fn parse_expr(text: &str) -> Expr {
|
||||
parse_atom(trimmed)
|
||||
}
|
||||
|
||||
/// Removes a zero offset from an address expression.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `expr` - Address expression.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The base expression when the offset is zero, otherwise the expression.
|
||||
fn drop_zero(expr: Expr) -> Expr {
|
||||
match expr {
|
||||
Expr::Bin("+", base, rhs) if *rhs == Expr::Imm(0) => *base,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a leading x86 operand size prefix.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -803,13 +855,68 @@ fn is_hex_token(token: &str) -> bool {
|
||||
///
|
||||
/// The destination and source operands when present.
|
||||
fn split_operands(operands: &str) -> (Option<&str>, Option<&str>, Option<&str>) {
|
||||
let mut parts = operands.split(',');
|
||||
let dst = nonempty(parts.next().unwrap_or_default());
|
||||
let src = parts.next().and_then(nonempty);
|
||||
let extra = parts.next().and_then(nonempty);
|
||||
let cuts = top_level_commas(operands);
|
||||
let parts = slice_parts(operands, &cuts);
|
||||
let dst = parts.first().copied().and_then(nonempty);
|
||||
let src = parts.get(1).copied().and_then(nonempty);
|
||||
let extra = parts.get(2).copied().and_then(nonempty);
|
||||
(dst, src, extra)
|
||||
}
|
||||
|
||||
/// Finds top-level comma positions, ignoring brackets.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - Operand text.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Byte indices of commas outside any `[]`, `()`, or `{}`.
|
||||
fn top_level_commas(text: &str) -> Vec<usize> {
|
||||
let mut state = (0i32, Vec::new());
|
||||
for (index, c) in text.char_indices() {
|
||||
step_depth(&mut state, index, c);
|
||||
}
|
||||
state.1
|
||||
}
|
||||
|
||||
/// Updates nesting depth and records top-level commas.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `state` - Running depth and comma positions.
|
||||
/// * `index` - Current byte index.
|
||||
/// * `c` - Current character.
|
||||
fn step_depth(state: &mut (i32, Vec<usize>), index: usize, c: char) {
|
||||
match c {
|
||||
'[' | '(' | '{' => state.0 += 1,
|
||||
']' | ')' | '}' => state.0 -= 1,
|
||||
',' if state.0 == 0 => state.1.push(index),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Slices text at the given byte positions.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - Source text.
|
||||
/// * `cuts` - Byte indices of separators.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The substrings between the separators.
|
||||
fn slice_parts<'a>(text: &'a str, cuts: &[usize]) -> Vec<&'a str> {
|
||||
let mut parts = Vec::new();
|
||||
let mut start = 0;
|
||||
for &cut in cuts {
|
||||
parts.push(&text[start..cut]);
|
||||
start = cut + 1;
|
||||
}
|
||||
parts.push(&text[start..]);
|
||||
parts
|
||||
}
|
||||
|
||||
/// Returns trimmed operand text when non-empty.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -917,7 +1024,7 @@ fn joined_pair(mnemonic: &str, dst: &str, src: &str) -> String {
|
||||
/// The address expression without a dereference.
|
||||
fn address_of(text: &str) -> Expr {
|
||||
let inner = bracketed(strip_size(text.trim()));
|
||||
inner.map_or_else(|| parse_expr(text), parse_sum)
|
||||
inner.map_or_else(|| parse_expr(text), |sum| drop_zero(parse_sum(sum)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -989,6 +1096,41 @@ mod tests {
|
||||
assert_eq!(lifted.inst, Inst::Ret);
|
||||
}
|
||||
|
||||
/// Verifies bracketed ARM operands are not split on the inner comma.
|
||||
#[test]
|
||||
fn test_split_operands_bracket() {
|
||||
assert_eq!(
|
||||
split_operands("r3,[r3,#0]"),
|
||||
(Some("r3"), Some("[r3,#0]"), None)
|
||||
);
|
||||
}
|
||||
|
||||
/// Verifies an ARM load dereferences its base register.
|
||||
#[test]
|
||||
fn test_lift_arm_load() {
|
||||
let lifted = lift(&line(0x1000, "ldrb r3,[r3,#0]"), Arch::Arm);
|
||||
let expected = Inst::Assign {
|
||||
dst: Expr::Reg("r3".into()),
|
||||
src: Expr::Mem(Box::new(Expr::Reg("r3".into()))),
|
||||
};
|
||||
assert_eq!(lifted.inst, expected);
|
||||
}
|
||||
|
||||
/// Verifies an ARM store orders memory as the destination.
|
||||
#[test]
|
||||
fn test_lift_arm_store_order() {
|
||||
let lifted = lift(&line(0x1000, "str r3,[r6,#0]"), Arch::Arm);
|
||||
let expected = Inst::Store { addr: Expr::Reg("r6".into()), src: Expr::Reg("r3".into()) };
|
||||
assert_eq!(lifted.inst, expected);
|
||||
}
|
||||
|
||||
/// Verifies an ARM width suffix is stripped before matching.
|
||||
#[test]
|
||||
fn test_lift_arm_width_suffix() {
|
||||
let lifted = lift(&line(0x1000, "strb.w r3,[sp,#7]"), Arch::Arm);
|
||||
assert!(matches!(lifted.inst, Inst::Store { .. }));
|
||||
}
|
||||
|
||||
/// Verifies that a Thumb conditional branch is recognized.
|
||||
#[test]
|
||||
fn test_lift_thumb_conditional() {
|
||||
|
||||
+522
-96
@@ -45,15 +45,35 @@ use crate::tools::{self, ToolContext};
|
||||
/// Default model used when `OPENROUTER_MODEL` is unset.
|
||||
const DEFAULT_MODEL: &str = "deepseek/deepseek-v4.1-flash";
|
||||
|
||||
/// Fallback models tried when the primary model is unavailable.
|
||||
const FALLBACK_MODELS: [&str; 2] = [
|
||||
"deepseek/deepseek-v4.1-flash:free",
|
||||
"nvidia/nemotron-3.5-lightning:free",
|
||||
];
|
||||
|
||||
/// System prompt for the autonomous tool-using analyst agent.
|
||||
const AGENT_PROMPT: &str = "You are a principal reverse engineering analyst and the AUTHOR of the \
|
||||
final security assessment report. You have tools to inspect the target. Investigate autonomously \
|
||||
and ground every claim in tool observations and the provided evidence; never fabricate strings, \
|
||||
offsets, keys, or behavior. Call tools as often as needed, then write the complete report \
|
||||
yourself. Your report MUST contain the sections: Executive Overview; Target Summary; Architecture \
|
||||
Analysis; Program Logic; Recovered Secrets (flags, keys, C2, credentials); Indicators of \
|
||||
Compromise; and Recommendations. Cite concrete addresses and strings from the evidence. When a \
|
||||
fact is unknown or encrypted, state that plainly rather than guessing.";
|
||||
const AGENT_PROMPT: &str = "You are a principal reverse engineering analyst writing a \
|
||||
nation-state-grade intelligence assessment. You are the SOLE AUTHOR of the report: the evidence \
|
||||
dossier and tool results are your source material, and the deterministic findings appear after \
|
||||
your narrative as an evidence appendix. Investigate autonomously with the tools and never \
|
||||
fabricate strings, offsets, keys, or behavior.\n\nWrite the report in this exact structure:\n1. \
|
||||
BLUF - Bottom Line Up Front: 3-6 sentences with what the target is, what it does, the single most \
|
||||
important finding, and the overall risk.\n2. Target Identification & Provenance: family, hashes, \
|
||||
size/entropy, format/architecture, compiler/toolchain, build timestamps, symbols.\n3. Capability \
|
||||
Assessment: ATT&CK/MBC-mapped capabilities, cryptography, and network behavior, each with a \
|
||||
confidence of High, Medium, or Low and its evidence.\n4. Behavioral Analysis: an end-to-end \
|
||||
plain-English description of what the program does - entry flow, initialization, main \
|
||||
loop/state machine, I/O, persistence, and control flow.\n5. Function Analysis: for EACH key \
|
||||
function, a real English summary of what it does (inputs, outputs, control flow, side effects), \
|
||||
its callers/callees, and notable strings or constants, citing addresses.\n6. Network, C2 & IOCs.\n\
|
||||
7. Secrets & Cryptography.\n8. Vulnerabilities & Exploitation.\n9. Anti-Analysis & Evasion.\n10. \
|
||||
Attribution & Tooling: toolchain and build-environment clues and any actor/campaign indicators, \
|
||||
with confidence.\n11. Assessment Confidence & Limitations: what is known, what is inferred, and \
|
||||
what could not be determined.\n\nRules: explain the assembler in human terms rather than \
|
||||
restating mnemonics; attach evidence and a confidence to every non-obvious claim; prefer concrete \
|
||||
statements over hedging; and when a fact is unknown or encrypted, say so plainly rather than \
|
||||
guessing.\n\nOutput ONLY the report itself, beginning with its title and section 1. Never include \
|
||||
a preamble, process commentary, or notes about the tools you used or your investigation.";
|
||||
|
||||
/// Instruction appended to the evidence dossier describing the model's role.
|
||||
const TOOL_NOTE: &str =
|
||||
@@ -64,9 +84,33 @@ report: expand it into an authoritative write-up.";
|
||||
/// Maximum agent turns before stopping.
|
||||
const MAX_TURNS: usize = 90;
|
||||
|
||||
/// System prompt for the adversarial adjudicator.
|
||||
const ADJUDICATOR_PROMPT: &str = "You are an adversarial adjudicator verifying a reverse \
|
||||
engineering report. Given the EVIDENCE, a DRAFT report, and REVIEWER FINDINGS, check every claim. \
|
||||
Remove or rewrite any statement not supported by the evidence, correct any unsupported address or \
|
||||
string, and keep the report's structure and writing quality. Output ONLY the corrected final \
|
||||
report, with no preamble or commentary.";
|
||||
|
||||
/// Focus areas for the adversarial reviewers.
|
||||
const REVIEW_FOCUSES: [&str; 3] = [
|
||||
"unsupported addresses, offsets, and literal strings",
|
||||
"behavioral or logic claims the evidence does not support",
|
||||
"secrets, keys, network endpoints, and attribution claims",
|
||||
];
|
||||
|
||||
/// System prompt for an adversarial reviewer.
|
||||
const REVIEWER_PROMPT: &str = "You are an adversarial reviewer of a reverse engineering report. \
|
||||
Given the EVIDENCE and the DRAFT report, list every claim that is not fully supported by the \
|
||||
evidence and any contradiction you find. If everything is supported, reply exactly: none. Output \
|
||||
only the list of problems, with no commentary.";
|
||||
|
||||
/// Tool specifications exposed to the model: name and description.
|
||||
const TOOL_SPECS: [(&str, &str); 11] = [
|
||||
const TOOL_SPECS: [(&str, &str); 12] = [
|
||||
("functions", "List recovered and key functions."),
|
||||
(
|
||||
"disasm",
|
||||
"Disassemble a function with its callers, callees, and referenced strings.",
|
||||
),
|
||||
("decompile", "Decompile a function by name or hex address."),
|
||||
("strings", "Search recovered strings for a substring."),
|
||||
("read", "Hex dump bytes at an address: '0xADDR LEN'."),
|
||||
@@ -97,12 +141,13 @@ const TOOL_SPECS: [(&str, &str); 11] = [
|
||||
),
|
||||
];
|
||||
|
||||
/// Minimum quotation length considered by the grounding validator.
|
||||
const GROUNDING_MIN: usize = 6;
|
||||
|
||||
/// Maximum unverified quotations listed in the grounding note.
|
||||
/// Maximum unverified quotations listed in the verification note.
|
||||
const GROUNDING_LIMIT: usize = 8;
|
||||
|
||||
/// Banner prepended to a report whose citations are all proven.
|
||||
const VERIFIED_NOTE: &str =
|
||||
"[Verification] All cited addresses and strings were proven against the engine evidence.\n\n";
|
||||
|
||||
/// Wall-clock budget for the analyst loop before forcing a final answer.
|
||||
const AI_BUDGET: Duration = Duration::from_secs(420);
|
||||
|
||||
@@ -112,12 +157,26 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// `OpenRouter` chat completions endpoint.
|
||||
const ENDPOINT: &str = "https://openrouter.ai/api/v1/chat/completions";
|
||||
|
||||
/// Maximum output tokens requested per turn.
|
||||
const MAX_OUTPUT: u32 = 16000;
|
||||
|
||||
/// Maximum characters of one tool observation fed back into the conversation.
|
||||
const OBS_LIMIT: usize = 4000;
|
||||
const OBS_LIMIT: usize = 24000;
|
||||
|
||||
/// Instruction that forces the model to stop investigating and write the report.
|
||||
const FINAL_NUDGE: &str = "Stop investigating now and write the complete final report using the \
|
||||
evidence gathered so far. Do not call any more tools.";
|
||||
evidence gathered so far. Do not call any more tools, and output only the report itself with no \
|
||||
preamble, process commentary, or notes about the tools you used.";
|
||||
|
||||
/// Reports whether the adversarial adjudication pass is enabled.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` when `REA_VERIFY` is set to `1`, `true`, or `yes`.
|
||||
fn verify_enabled() -> bool {
|
||||
let on = |v: String| matches!(v.as_str(), "1" | "true" | "yes");
|
||||
std::env::var("REA_VERIFY").is_ok_and(on)
|
||||
}
|
||||
|
||||
/// Consecutive no-progress turns before the loop stops investigating.
|
||||
const STALE_LIMIT: usize = 3;
|
||||
@@ -199,6 +258,53 @@ fn model_name() -> String {
|
||||
std::env::var("OPENROUTER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())
|
||||
}
|
||||
|
||||
/// Builds the ordered model chain: primary first, then fallbacks.
|
||||
///
|
||||
/// The chain lets a depleted or rate-limited model fail over to a free model
|
||||
/// so an assessment is still produced.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The de-duplicated model identifiers.
|
||||
fn models() -> Vec<String> {
|
||||
let mut list = vec![model_name()];
|
||||
for fallback in fallback_models() {
|
||||
if !list.contains(&fallback) {
|
||||
list.push(fallback);
|
||||
}
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
/// Resolves the fallback models from `REA_FALLBACK_MODELS` or the defaults.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The fallback model identifiers.
|
||||
fn fallback_models() -> Vec<String> {
|
||||
match std::env::var("REA_FALLBACK_MODELS") {
|
||||
Ok(text) => split_models(&text),
|
||||
Err(_) => FALLBACK_MODELS.iter().map(ToString::to_string).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a comma-separated model list.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - Comma-separated model identifiers.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The trimmed, non-empty model identifiers.
|
||||
fn split_models(text: &str) -> Vec<String> {
|
||||
text.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Constructs a tool-enabled chat completion JSON payload.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -215,10 +321,23 @@ fn build_payload(model: &str, messages: &serde_json::Value) -> serde_json::Value
|
||||
"messages": messages,
|
||||
"tools": tool_defs(),
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.2
|
||||
"temperature": 0.2,
|
||||
"max_tokens": max_output()
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the per-turn output-token cap from `REA_MAX_TOKENS`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The configured cap, defaulting to [`MAX_OUTPUT`].
|
||||
fn max_output() -> u32 {
|
||||
std::env::var("REA_MAX_TOKENS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(MAX_OUTPUT)
|
||||
}
|
||||
|
||||
/// Builds the JSON tool definition array from [`TOOL_SPECS`].
|
||||
///
|
||||
/// # Returns
|
||||
@@ -317,21 +436,50 @@ fn extract_message(val: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
val.get("choices")?.get(0)?.get("message").cloned()
|
||||
}
|
||||
|
||||
/// Executes a single chat completion turn.
|
||||
/// Sends a chat completion turn, optionally forbidding tool calls.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `model` - Model identifier.
|
||||
/// * `messages` - Conversation message array.
|
||||
/// * `allow_tools` - Whether the model may call tools.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The assistant message object.
|
||||
fn chat(key: &str, model: &str, messages: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
let body = build_payload(model, messages);
|
||||
let val = send_request(key, &body).ok()?;
|
||||
extract_message(&val)
|
||||
/// The assistant message object, or `None` on failure.
|
||||
fn request(
|
||||
key: &str,
|
||||
model: &str,
|
||||
messages: &serde_json::Value,
|
||||
allow_tools: bool,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut body = build_payload(model, messages);
|
||||
if !allow_tools {
|
||||
body["tool_choice"] = json!("none");
|
||||
}
|
||||
send_one(key, &body, model)
|
||||
}
|
||||
|
||||
/// Sends one prepared request and logs failures.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `body` - Request JSON.
|
||||
/// * `model` - Model identifier, for diagnostics.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The assistant message object, or `None`.
|
||||
fn send_one(key: &str, body: &serde_json::Value, model: &str) -> Option<serde_json::Value> {
|
||||
match send_request(key, body) {
|
||||
Ok(val) => extract_message(&val),
|
||||
Err(err) => {
|
||||
eprintln!("rea: request to {model} failed: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends target identity facts to evidence text.
|
||||
@@ -465,7 +613,7 @@ fn append_code_header(out: &mut String, code: &rea_engine::disasm::Disassembly)
|
||||
fn append_code_evidence(out: &mut String, a: &Analysis) {
|
||||
let Some(code) = &a.disassembly else { return };
|
||||
append_code_header(out, code);
|
||||
for key in a.key_functions.iter().take(4) {
|
||||
for key in a.key_functions.iter().take(6) {
|
||||
append_key_func_listing(out, key);
|
||||
}
|
||||
}
|
||||
@@ -478,7 +626,10 @@ fn append_code_evidence(out: &mut String, a: &Analysis) {
|
||||
/// * `key` - Key function.
|
||||
fn append_key_func_listing(out: &mut String, key: &rea_engine::insights::KeyFunction) {
|
||||
let _ = writeln!(out, "KEY FUNCTION: {} at {:#x}", key.name, key.address);
|
||||
for line in key.listing.iter().take(20) {
|
||||
for reason in key.reasons.iter().take(4) {
|
||||
let _ = writeln!(out, " why: {reason}");
|
||||
}
|
||||
for line in key.listing.iter().take(rea_engine::insights::LISTING_LINES) {
|
||||
let _ = writeln!(out, " {line}");
|
||||
}
|
||||
}
|
||||
@@ -556,6 +707,65 @@ fn initial_messages(evidence: &str) -> serde_json::Value {
|
||||
])
|
||||
}
|
||||
|
||||
/// Builds the adjudication messages for a draft report.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `evidence` - Evidence dossier text.
|
||||
/// * `draft` - Author's draft report.
|
||||
/// * `findings` - Concatenated reviewer findings.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The message array.
|
||||
fn adjudication_messages(evidence: &str, draft: &str, findings: &str) -> serde_json::Value {
|
||||
let user = format!(
|
||||
"EVIDENCE:\n{evidence}\n\nDRAFT REPORT:\n{draft}\n\nREVIEWER FINDINGS:\n{findings}"
|
||||
);
|
||||
json!([
|
||||
{"role": "system", "content": ADJUDICATOR_PROMPT},
|
||||
{"role": "user", "content": user}
|
||||
])
|
||||
}
|
||||
|
||||
/// Builds the reviewer messages for a draft report.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `evidence` - Evidence dossier text.
|
||||
/// * `draft` - Author's draft report.
|
||||
/// * `focus` - Area the reviewer scrutinizes.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The message array.
|
||||
fn review_messages(evidence: &str, draft: &str, focus: &str) -> serde_json::Value {
|
||||
let system = format!("{REVIEWER_PROMPT} Focus on {focus}.");
|
||||
let user = format!("EVIDENCE:\n{evidence}\n\nDRAFT REPORT:\n{draft}");
|
||||
json!([
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user}
|
||||
])
|
||||
}
|
||||
|
||||
/// Selects the reviewer focus areas to run.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The focus areas, bounded by `REA_REVIEWERS`.
|
||||
fn review_focuses() -> impl Iterator<Item = &'static str> {
|
||||
REVIEW_FOCUSES.into_iter().take(review_count())
|
||||
}
|
||||
|
||||
/// Resolves the reviewer count from `REA_REVIEWERS`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The configured count, defaulting to two reviewers.
|
||||
fn review_count() -> usize {
|
||||
std::env::var("REA_REVIEWERS").ok().and_then(|s| s.parse().ok()).unwrap_or(2)
|
||||
}
|
||||
|
||||
/// Reads a string field from a JSON value.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -633,6 +843,7 @@ fn append_tool(messages: &mut serde_json::Value, id: &str, content: &str) {
|
||||
}
|
||||
|
||||
/// Conversation state for one analyst run.
|
||||
#[derive(Default)]
|
||||
struct Session {
|
||||
/// Conversation message array.
|
||||
messages: serde_json::Value,
|
||||
@@ -640,11 +851,20 @@ struct Session {
|
||||
/// Evidence corpus for grounding.
|
||||
seen: String,
|
||||
|
||||
/// Evidence dossier text for adjudication.
|
||||
evidence: String,
|
||||
|
||||
/// Signatures of tool calls already executed.
|
||||
called: BTreeSet<String>,
|
||||
|
||||
/// Consecutive turns that produced no new tool call.
|
||||
stale: usize,
|
||||
|
||||
/// Ordered model chain, primary first.
|
||||
models: Vec<String>,
|
||||
|
||||
/// Index of the model currently in use.
|
||||
current: usize,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -654,13 +874,170 @@ impl Session {
|
||||
///
|
||||
/// * `evidence` - Evidence dossier text.
|
||||
/// * `corpus` - Evidence plus full string set for grounding.
|
||||
/// * `models` - Ordered model chain.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new session.
|
||||
fn new(evidence: &str, corpus: &str) -> Self {
|
||||
let messages = initial_messages(evidence);
|
||||
Self { messages, seen: corpus.to_string(), called: BTreeSet::new(), stale: 0 }
|
||||
fn new(evidence: &str, corpus: &str, models: Vec<String>) -> Self {
|
||||
let mut session = Self::empty(models);
|
||||
session.messages = initial_messages(evidence);
|
||||
session.seen = corpus.to_string();
|
||||
session.evidence = evidence.to_string();
|
||||
session
|
||||
}
|
||||
|
||||
/// Runs an adversarial adjudication pass, falling back to the draft.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `draft` - Author's draft report.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The verified report.
|
||||
fn adjudicate(&self, key: &str, draft: &str) -> String {
|
||||
if !verify_enabled() {
|
||||
return ground(draft, &self.seen);
|
||||
}
|
||||
let findings = self.review_all(key, draft);
|
||||
let final_text = self.settle(key, draft, &findings);
|
||||
ground(&final_text, &self.seen)
|
||||
}
|
||||
|
||||
/// Runs every adversarial reviewer over the draft.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `draft` - Author's draft report.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The concatenated reviewer findings.
|
||||
fn review_all(&self, key: &str, draft: &str) -> String {
|
||||
let mut findings = String::new();
|
||||
for focus in review_focuses() {
|
||||
if let Some(text) = self.review(key, draft, focus) {
|
||||
findings.push_str(&text);
|
||||
findings.push('\n');
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
/// Runs one adversarial reviewer focused on an area.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `draft` - Author's draft report.
|
||||
/// * `focus` - Area the reviewer scrutinizes.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The reviewer findings, or `None`.
|
||||
fn review(&self, key: &str, draft: &str, focus: &str) -> Option<String> {
|
||||
let messages = review_messages(&self.evidence, draft, focus);
|
||||
self.ask_context(key, &messages)
|
||||
}
|
||||
|
||||
/// Adjudicates the draft against the reviewer findings.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `draft` - Author's draft report.
|
||||
/// * `findings` - Concatenated reviewer findings.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The corrected report, or the draft when there were no findings.
|
||||
fn settle(&self, key: &str, draft: &str, findings: &str) -> String {
|
||||
if findings.trim().is_empty() {
|
||||
return draft.to_string();
|
||||
}
|
||||
let messages = adjudication_messages(&self.evidence, draft, findings);
|
||||
self.ask_context(key, &messages).unwrap_or_else(|| draft.to_string())
|
||||
}
|
||||
|
||||
/// Sends one non-tool turn and returns its text content.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `messages` - Conversation message array.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The message content, or `None`.
|
||||
fn ask_context(&self, key: &str, messages: &serde_json::Value) -> Option<String> {
|
||||
let content = request(key, self.active_model(), messages, false);
|
||||
content
|
||||
.map(|message| text_field(&message, "content"))
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Creates a session with no conversation yet.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `models` - Ordered model chain.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A blank session.
|
||||
fn empty(models: Vec<String>) -> Self {
|
||||
Self { models, ..Self::default() }
|
||||
}
|
||||
|
||||
/// Sends a turn, failing over to the next model on error.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `allow_tools` - Whether the model may call tools.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The assistant message object, or `None` when every model failed.
|
||||
fn ask(&mut self, key: &str, allow_tools: bool) -> Option<serde_json::Value> {
|
||||
while self.current < self.models.len() {
|
||||
if let Some(message) = self.try_current(key, allow_tools) {
|
||||
return Some(message);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Requests from the current model, advancing on failure.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `allow_tools` - Whether the model may call tools.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The assistant message object, or `None` on failure.
|
||||
fn try_current(&mut self, key: &str, allow_tools: bool) -> Option<serde_json::Value> {
|
||||
let model = self.models[self.current].clone();
|
||||
let message = request(key, &model, &self.messages, allow_tools);
|
||||
if message.is_none() {
|
||||
eprintln!("rea: model {model} unavailable; failing over");
|
||||
self.current += 1;
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
/// Returns the model currently in use.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The active model identifier.
|
||||
fn active_model(&self) -> &str {
|
||||
self.models.get(self.current).map_or("none", String::as_str)
|
||||
}
|
||||
|
||||
/// Advances one turn, executing tools or returning the final report.
|
||||
@@ -677,35 +1054,45 @@ impl Session {
|
||||
let calls = calls_of(message);
|
||||
self.messages.as_array_mut()?.push(message.clone());
|
||||
if calls.is_empty() {
|
||||
return final_report(message, &self.seen);
|
||||
return self.blank_or_report(message);
|
||||
}
|
||||
self.run_calls(&calls, ctx);
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns the final report, or counts an empty reply as a stale turn.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - Assistant message object.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The grounded report, or `None` when the reply was empty.
|
||||
fn blank_or_report(&mut self, message: &serde_json::Value) -> Option<String> {
|
||||
let report = final_report(message);
|
||||
if report.is_none() {
|
||||
self.stale += 1;
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Runs the loop until a report is produced, the budget expires, or turns run out.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `model` - Model identifier.
|
||||
/// * `ctx` - Tool context.
|
||||
/// * `deadline` - Wall-clock budget deadline.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The final report, or `None` when the budget or turn limit is reached.
|
||||
fn run(
|
||||
&mut self,
|
||||
key: &str,
|
||||
model: &str,
|
||||
ctx: &ToolContext<'_>,
|
||||
deadline: Instant,
|
||||
) -> Option<String> {
|
||||
fn run(&mut self, key: &str, ctx: &ToolContext<'_>, deadline: Instant) -> Option<String> {
|
||||
let mut turn = 0;
|
||||
while !expired(deadline) && turn < MAX_TURNS && self.stale < STALE_LIMIT {
|
||||
turn += 1;
|
||||
if let Some(text) = self.turn(turn, key, model, ctx) {
|
||||
if let Some(text) = self.turn(turn, key, ctx) {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
@@ -718,21 +1105,14 @@ impl Session {
|
||||
///
|
||||
/// * `turn` - One-based turn number.
|
||||
/// * `key` - API key.
|
||||
/// * `model` - Model identifier.
|
||||
/// * `ctx` - Tool context.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The final report when the model stops calling tools.
|
||||
fn turn(
|
||||
&mut self,
|
||||
turn: usize,
|
||||
key: &str,
|
||||
model: &str,
|
||||
ctx: &ToolContext<'_>,
|
||||
) -> Option<String> {
|
||||
eprintln!("rea: agent turn {turn}");
|
||||
let message = chat(key, model, &self.messages)?;
|
||||
fn turn(&mut self, turn: usize, key: &str, ctx: &ToolContext<'_>) -> Option<String> {
|
||||
eprintln!("rea: agent turn {turn} ({})", self.active_model());
|
||||
let message = self.ask(key, true)?;
|
||||
self.finish(&message, ctx)
|
||||
}
|
||||
|
||||
@@ -741,15 +1121,14 @@ impl Session {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `model` - Model identifier.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The grounded report, or `None`.
|
||||
fn finalize(&mut self, key: &str, model: &str) -> Option<String> {
|
||||
fn finalize(&mut self, key: &str) -> Option<String> {
|
||||
push_user(&mut self.messages, FINAL_NUDGE);
|
||||
let message = chat_none(key, model, &self.messages)?;
|
||||
final_report(&message, &self.seen)
|
||||
let message = self.ask(key, false)?;
|
||||
final_report(&message)
|
||||
}
|
||||
|
||||
/// Executes tool calls, folding observations into the corpus.
|
||||
@@ -846,39 +1225,23 @@ fn push_user(messages: &mut serde_json::Value, content: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes a chat turn that forbids further tool calls.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `model` - Model identifier.
|
||||
/// * `messages` - Conversation message array.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The assistant message object.
|
||||
fn chat_none(key: &str, model: &str, messages: &serde_json::Value) -> Option<serde_json::Value> {
|
||||
let mut body = build_payload(model, messages);
|
||||
body["tool_choice"] = json!("none");
|
||||
send_request(key, &body).ok().and_then(|val| extract_message(&val))
|
||||
}
|
||||
|
||||
/// Produces the final grounded report from an assistant message.
|
||||
/// Extracts the author's draft report from an assistant message.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `message` - Assistant message object.
|
||||
/// * `seen` - Evidence dossier plus tool observations.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The grounded report, or `None` when the reply is empty.
|
||||
fn final_report(message: &serde_json::Value, seen: &str) -> Option<String> {
|
||||
/// The draft report, or `None` when the reply is empty.
|
||||
fn final_report(message: &serde_json::Value) -> Option<String> {
|
||||
let content = text_field(message, "content");
|
||||
(!content.trim().is_empty()).then(|| ground(&content, seen))
|
||||
(!content.trim().is_empty()).then_some(content)
|
||||
}
|
||||
|
||||
/// Flags report quotations that are absent from the evidence corpus.
|
||||
/// Verifies report citations against the evidence corpus.
|
||||
///
|
||||
/// Every cited address and quoted string must exist in the engine evidence.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -887,17 +1250,17 @@ fn final_report(message: &serde_json::Value, seen: &str) -> Option<String> {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The report, annotated when unverified quotations are found.
|
||||
/// The report with a verification note appended.
|
||||
fn ground(text: &str, seen: &str) -> String {
|
||||
let bad = unverified(text, seen);
|
||||
if bad.is_empty() {
|
||||
return text.to_string();
|
||||
return format!("{VERIFIED_NOTE}{text}");
|
||||
}
|
||||
let note = format!("{} unverified quotation(s): {}", bad.len(), sample(&bad));
|
||||
format!("{text}\n\n[Grounding] {note}")
|
||||
let note = format!("{} unverified citation(s): {}", bad.len(), sample(&bad));
|
||||
format!("{text}\n\n[Verification] {note}")
|
||||
}
|
||||
|
||||
/// Selects report quotations absent from the evidence corpus.
|
||||
/// Selects report citations absent from the evidence corpus.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -906,10 +1269,46 @@ fn ground(text: &str, seen: &str) -> String {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The unverified quotations.
|
||||
/// The unverified citations.
|
||||
fn unverified(text: &str, seen: &str) -> Vec<String> {
|
||||
let kept = |q: &String| q.len() >= GROUNDING_MIN && !seen.contains(q.as_str());
|
||||
quotes(text).into_iter().filter(kept).collect()
|
||||
let mut tokens = quotes(text);
|
||||
tokens.extend(addresses(text));
|
||||
tokens.retain(|token| !proven(seen, token));
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Reports whether a citation exists in the evidence corpus.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `seen` - Evidence dossier plus tool observations.
|
||||
/// * `token` - Cited address or string.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` when the token is present in the corpus.
|
||||
fn proven(seen: &str, token: &str) -> bool {
|
||||
seen.contains(token) || seen.contains(token.trim_start_matches("0x"))
|
||||
}
|
||||
|
||||
/// Collects hexadecimal addresses cited in report text.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - Report text.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The cited addresses.
|
||||
fn addresses(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for part in text.split("0x").skip(1) {
|
||||
let hex: String = part.chars().take_while(char::is_ascii_hexdigit).collect();
|
||||
if hex.len() >= 3 {
|
||||
out.push(format!("0x{hex}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Builds a bounded comma-separated sample of strings.
|
||||
@@ -944,7 +1343,6 @@ fn quotes(text: &str) -> Vec<String> {
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - API key.
|
||||
/// * `model` - Model identifier.
|
||||
/// * `ctx` - Tool context.
|
||||
/// * `evidence` - Evidence dossier.
|
||||
/// * `corpus` - Evidence plus full string set for grounding.
|
||||
@@ -952,17 +1350,11 @@ fn quotes(text: &str) -> Vec<String> {
|
||||
/// # Returns
|
||||
///
|
||||
/// The final report, or `None`.
|
||||
fn run_loop(
|
||||
key: &str,
|
||||
model: &str,
|
||||
ctx: &ToolContext<'_>,
|
||||
evidence: &str,
|
||||
corpus: &str,
|
||||
) -> Option<String> {
|
||||
let mut session = Session::new(evidence, corpus);
|
||||
fn run_loop(key: &str, ctx: &ToolContext<'_>, evidence: &str, corpus: &str) -> Option<String> {
|
||||
let mut session = Session::new(evidence, corpus, models());
|
||||
let deadline = Instant::now() + ai_budget();
|
||||
let draft = session.run(key, model, ctx, deadline);
|
||||
draft.or_else(|| session.finalize(key, model))
|
||||
let draft = session.run(key, ctx, deadline).or_else(|| session.finalize(key))?;
|
||||
Some(session.adjudicate(key, &draft))
|
||||
}
|
||||
|
||||
/// Resolves the analyst wall-clock budget from `REA_AGENT_BUDGET_SECS`.
|
||||
@@ -988,12 +1380,27 @@ fn ai_budget() -> Duration {
|
||||
#[must_use]
|
||||
pub fn evaluate(analysis: &Analysis, bytes: &[u8]) -> Option<String> {
|
||||
let key = load_api_key()?;
|
||||
let model = model_name();
|
||||
eprintln!("rea: running autonomous AI analyst loop ({model})");
|
||||
eprintln!("rea: autonomous AI analyst loop: {}", models().join(" -> "));
|
||||
let evidence = build_evidence(analysis);
|
||||
let corpus = corpus(analysis, &evidence);
|
||||
let ctx = ToolContext { analysis, bytes };
|
||||
run_loop(&key, &model, &ctx, &evidence, &corpus)
|
||||
announce(run_loop(&key, &ctx, &evidence, &corpus))
|
||||
}
|
||||
|
||||
/// Reports whether the analyst produced a report.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `report` - Analyst output, when produced.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The report, unchanged.
|
||||
fn announce(report: Option<String>) -> Option<String> {
|
||||
if report.is_none() {
|
||||
eprintln!("rea: AI assessment unavailable; check OpenRouter credits and OPENROUTER_MODEL");
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Builds the grounding corpus from the dossier and every extracted string.
|
||||
@@ -1072,4 +1479,23 @@ mod tests {
|
||||
let defs = tool_defs();
|
||||
assert_eq!(defs.as_array().unwrap().len(), TOOL_SPECS.len());
|
||||
}
|
||||
|
||||
/// Verifies the request payload bounds output tokens and exposes tools.
|
||||
#[test]
|
||||
fn test_payload_bounds_output() {
|
||||
let payload = build_payload("m", &json!([]));
|
||||
assert_eq!(payload["max_tokens"], MAX_OUTPUT);
|
||||
assert!(payload["tools"].is_array());
|
||||
}
|
||||
|
||||
/// Verifies the model chain starts with the primary and has fallbacks.
|
||||
#[test]
|
||||
fn test_models_chain() {
|
||||
let chain = models();
|
||||
assert_eq!(
|
||||
chain.first().map(String::as_str),
|
||||
Some(model_name().as_str())
|
||||
);
|
||||
assert!(chain.len() >= 2);
|
||||
}
|
||||
}
|
||||
+149
-88
@@ -69,12 +69,12 @@ const KEY_NOTE: &str = "Inferred: functions are ranked by the capability-relevan
|
||||
call and the indicators they reference. Listings use Intel syntax with imports, functions, and \
|
||||
strings resolved in comments.";
|
||||
|
||||
/// Note placed above the full walkthrough.
|
||||
/// Note placed above the function analysis.
|
||||
const WALKTHROUGH_NOTE: &str =
|
||||
"Observed: the complete assembler for every recovered function is written to \
|
||||
re-<name>.asm. This section breaks down the largest recovered functions: the raw listing, a \
|
||||
plain-language summary of what the function does, and dual code/data analysis of any \
|
||||
embedded literal pools.";
|
||||
"Observed: the full instruction stream for every recovered function is written to \
|
||||
re-<name>.asm. This section summarizes what each recovered function does in plain language, \
|
||||
with its call relationships and embedded literal pools. Read the .asm artifact when you need \
|
||||
the raw assembly.";
|
||||
|
||||
/// Data pool table columns.
|
||||
const DATA_HEADERS: [&str; 5] = [
|
||||
@@ -91,11 +91,11 @@ const DECOMP_CAP: usize = 200;
|
||||
/// Most functions per report rendered as recovered pseudo-C.
|
||||
const DECOMP_LIMIT: usize = 64;
|
||||
|
||||
/// Most functions rendered in the assembly walkthrough.
|
||||
/// Most functions rendered in the function analysis.
|
||||
const WALKTHROUGH_FUNCTIONS: usize = 16;
|
||||
|
||||
/// Most raw instructions listed per walkthrough function.
|
||||
const RAW_CAP: usize = 160;
|
||||
/// Most literal-pool rows shown per function.
|
||||
const DATA_POOL_CAP: usize = 12;
|
||||
|
||||
/// Builds the code analysis and key function sections.
|
||||
///
|
||||
@@ -378,12 +378,31 @@ fn walkthrough(analysis: &Analysis, code: &Disassembly) -> Section {
|
||||
let section = walkthrough_heading();
|
||||
let ev = build_walkthrough_evidence(analysis, code);
|
||||
let selected = decompile_targets(code);
|
||||
let funcs = sorted_functions(code).into_iter().take(WALKTHROUGH_FUNCTIONS);
|
||||
funcs.fold(section, |s, f| {
|
||||
let funcs = ranked_functions(analysis, code);
|
||||
funcs.into_iter().fold(section, |s, f| {
|
||||
with_function_walkthrough(s, analysis, ev.as_ref(), code, f, &selected)
|
||||
})
|
||||
}
|
||||
|
||||
/// Lists the analyst-ranked functions, resolved to recovered functions.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `analysis` - Completed analysis.
|
||||
/// * `code` - Recovered code structure.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The ranked functions in descending relevance.
|
||||
fn ranked_functions<'a>(analysis: &'a Analysis, code: &'a Disassembly) -> Vec<&'a Function> {
|
||||
analysis
|
||||
.key_functions
|
||||
.iter()
|
||||
.take(WALKTHROUGH_FUNCTIONS)
|
||||
.filter_map(|key| code.functions.get(&key.address))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Builds the walkthrough section heading.
|
||||
///
|
||||
/// # Returns
|
||||
@@ -391,22 +410,7 @@ fn walkthrough(analysis: &Analysis, code: &Disassembly) -> Section {
|
||||
/// The section with its explanatory note.
|
||||
fn walkthrough_heading() -> Section {
|
||||
let note = text::paragraph(WALKTHROUGH_NOTE);
|
||||
Section::new("Assembly Walkthrough & Instruction Analysis").with(note)
|
||||
}
|
||||
|
||||
/// Lists the recovered functions by address.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `code` - Recovered code structure.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The functions in ascending address order.
|
||||
fn sorted_functions(code: &Disassembly) -> Vec<&Function> {
|
||||
let mut funcs: Vec<&Function> = code.functions.values().collect();
|
||||
funcs.sort_by_key(|f| f.address);
|
||||
funcs
|
||||
Section::new("Function Analysis").with(note)
|
||||
}
|
||||
|
||||
/// Builds the evidence resolver precomputed with the recovered call graph.
|
||||
@@ -448,12 +452,91 @@ fn with_function_walkthrough(
|
||||
selected: &BTreeSet<u64>,
|
||||
) -> Section {
|
||||
let section = append_func_heading(section, code, func);
|
||||
let section = append_raw_listing(section, func);
|
||||
let section = append_selected(section, analysis, func, selected);
|
||||
let section = append_func_summary(section, ev, code, func);
|
||||
let section = append_selected(section, analysis, func, selected);
|
||||
let section = append_call_graph(section, code, func);
|
||||
append_data_pools(section, code, func)
|
||||
}
|
||||
|
||||
/// Appends the function's callers, callees, and imported calls.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `section` - Section being built.
|
||||
/// * `code` - Recovered code structure.
|
||||
/// * `func` - Function to describe.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The extended section.
|
||||
fn append_call_graph(section: Section, code: &Disassembly, func: &Function) -> Section {
|
||||
let targets: Vec<String> = func.calls.iter().map(|a| code.name_of(*a)).collect();
|
||||
let imports: Vec<String> = func.imports.iter().cloned().collect();
|
||||
let text = graph_text(code, func, &targets, &imports);
|
||||
section.with(Block::Paragraph(text))
|
||||
}
|
||||
|
||||
/// Builds the call-graph sentence for a function.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `code` - Recovered code structure.
|
||||
/// * `func` - Function to describe.
|
||||
/// * `targets` - Callee names.
|
||||
/// * `imports` - Imported call names.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The sentence.
|
||||
fn graph_text(
|
||||
code: &Disassembly,
|
||||
func: &Function,
|
||||
targets: &[String],
|
||||
imports: &[String],
|
||||
) -> String {
|
||||
let inbound = callers_of(code, func.address);
|
||||
let (calls, used) = (join_names(targets), join_names(imports));
|
||||
format!(
|
||||
"Calls: {calls}. Called by: {}. Imports used: {used}.",
|
||||
join_names(&inbound)
|
||||
)
|
||||
}
|
||||
|
||||
/// Joins names or returns `none` when empty.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `names` - Names to join.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The joined text.
|
||||
fn join_names(names: &[String]) -> String {
|
||||
if names.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
names.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects the callers of a function.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `code` - Recovered code structure.
|
||||
/// * `target` - Callee address.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The caller names.
|
||||
fn callers_of(code: &Disassembly, target: u64) -> Vec<String> {
|
||||
code.functions
|
||||
.values()
|
||||
.filter(|f| f.calls.contains(&target))
|
||||
.map(|f| code.name_of(f.address))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Appends the function heading.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -467,68 +550,26 @@ fn with_function_walkthrough(
|
||||
/// The extended section.
|
||||
fn append_func_heading(section: Section, code: &Disassembly, func: &Function) -> Section {
|
||||
let name = code.name_of(func.address);
|
||||
let title = format!(
|
||||
"{name} at {} ({} instructions)",
|
||||
text::hex(func.address),
|
||||
func.instructions
|
||||
);
|
||||
let count = instructions_label(func.instructions);
|
||||
let title = format!("{name} at {} ({count})", text::hex(func.address));
|
||||
section.with(Block::Subheading(title))
|
||||
}
|
||||
|
||||
/// Appends the complete raw assembler listing for a function.
|
||||
/// Formats an instruction count with correct pluralization.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `section` - Section being built.
|
||||
/// * `func` - Function whose listing is rendered.
|
||||
/// * `count` - Instruction count.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The extended section.
|
||||
fn append_raw_listing(section: Section, func: &Function) -> Section {
|
||||
if func.lines.is_empty() {
|
||||
return section;
|
||||
}
|
||||
let listing = raw_lines(func);
|
||||
section.with(Block::Code(listing.join("\n")))
|
||||
}
|
||||
|
||||
/// Renders a function's raw listing within the per-function cap.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `func` - Function whose listing is rendered.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The listing lines, with an omission note when capped.
|
||||
fn raw_lines(func: &Function) -> Vec<String> {
|
||||
let mut lines: Vec<String> = func.lines.iter().take(RAW_CAP).map(format_line).collect();
|
||||
if func.lines.len() > RAW_CAP {
|
||||
lines.push(format!(
|
||||
"... {} more instructions; see re-<name>.asm",
|
||||
func.lines.len() - RAW_CAP
|
||||
));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Formats one raw assembler line with its address.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `line` - Decoded instruction line.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Text such as `0x1000023a: movs r1, #0x2b`.
|
||||
fn format_line(line: &rea_engine::disasm::InstructionLine) -> String {
|
||||
let comment = if line.discussion.is_empty() {
|
||||
String::new()
|
||||
/// The label text.
|
||||
fn instructions_label(count: usize) -> String {
|
||||
if count == 1 {
|
||||
"1 instruction".to_string()
|
||||
} else {
|
||||
format!(" ; {}", line.discussion)
|
||||
};
|
||||
format!("{}: {}{comment}", text::hex(line.address), line.text)
|
||||
format!("{count} instructions")
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends the plain-language summary of what the function does.
|
||||
@@ -549,11 +590,34 @@ fn append_func_summary(
|
||||
code: &Disassembly,
|
||||
func: &Function,
|
||||
) -> Section {
|
||||
let Some(ev) = ev else { return section };
|
||||
let story = narrative::function_story(ev, code, func);
|
||||
let story = match ev {
|
||||
Some(ev) => narrative::function_story(ev, code, func),
|
||||
None => vec![structural_summary(code, func)],
|
||||
};
|
||||
section.with(Block::Paragraph(story.join(" ")))
|
||||
}
|
||||
|
||||
/// Builds a structural summary when no parsed binary is available.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `code` - Recovered code structure.
|
||||
/// * `func` - Function to summarize.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The summary sentence.
|
||||
fn structural_summary(code: &Disassembly, func: &Function) -> String {
|
||||
let name = code.name_of(func.address);
|
||||
let (calls, imports, refs) = (func.calls.len(), func.imports.len(), func.references.len());
|
||||
let branches = func.lines.iter().filter(|l| l.kind == "Control Flow").count();
|
||||
format!(
|
||||
"{name} is a {}-instruction routine making {calls} internal and {imports} imported \
|
||||
call(s), with {branches} branch(es) and {refs} referenced address(es).",
|
||||
func.instructions
|
||||
)
|
||||
}
|
||||
|
||||
/// Appends literal pool and data analysis table.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -586,7 +650,7 @@ fn append_data_pools(section: Section, code: &Disassembly, func: &Function) -> S
|
||||
fn make_data_pool_blocks(code: &Disassembly, func: &Function) -> (String, rea_report::Table) {
|
||||
let name = code.name_of(func.address);
|
||||
let title = format!("{name} - Literal Pools & Embedded Data");
|
||||
let rows: Vec<Vec<String>> = func.data_pools.iter().map(data_row).collect();
|
||||
let rows: Vec<Vec<String>> = func.data_pools.iter().take(DATA_POOL_CAP).map(data_row).collect();
|
||||
(title, text::table(&DATA_HEADERS, rows))
|
||||
}
|
||||
|
||||
@@ -645,14 +709,11 @@ mod tests {
|
||||
assert!(sections(&rea_engine::analyze("x", b"abc")).is_empty());
|
||||
}
|
||||
|
||||
/// Verifies that walkthrough renders when disassembly exists.
|
||||
/// Verifies that function analysis renders when disassembly exists.
|
||||
#[test]
|
||||
fn test_walkthrough_section() {
|
||||
let analysis = rea_engine::analyze("x", b"abc");
|
||||
let section = walkthrough(&analysis, &Disassembly::default());
|
||||
assert_eq!(
|
||||
section.heading,
|
||||
"Assembly Walkthrough & Instruction Analysis"
|
||||
);
|
||||
assert_eq!(section.heading, "Function Analysis");
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,11 @@ const SECTIONS: [fn(&Analysis) -> Vec<Section>; 12] = [
|
||||
method::sections,
|
||||
];
|
||||
|
||||
/// Note placed above the deterministic evidence appendix.
|
||||
const APPENDIX_NOTE: &str = "Deterministic engine findings supporting the assessment above: \
|
||||
identity, structure, imports, strings, code inventory, capabilities, vulnerabilities, and \
|
||||
artifacts. This is raw evidence, not analysis.";
|
||||
|
||||
/// Extracts the content of a bullet or numbered list line.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -361,6 +366,9 @@ fn assessment_section(text: &str) -> Section {
|
||||
|
||||
/// Composes the complete report for one analysis.
|
||||
///
|
||||
/// When an AI assessment is present, the report is authored by the analyst and
|
||||
/// the deterministic findings become an evidence appendix.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `analysis` - Completed analysis.
|
||||
@@ -374,29 +382,58 @@ fn assessment_section(text: &str) -> Section {
|
||||
pub fn build(analysis: &Analysis, bytes: &[u8], assessment: Option<&str>) -> Document {
|
||||
let title = format!("Reverse Engineering Report: {}", analysis.name);
|
||||
let mut document = Document::new(&title, &subtitle(analysis));
|
||||
document.sections = SECTIONS.iter().flat_map(|b| b(analysis)).collect();
|
||||
document.sections.extend(rules::sections(analysis, bytes));
|
||||
document.sections.extend(crypto::sections(bytes));
|
||||
document.sections.extend(artifacts::sections(analysis, bytes));
|
||||
document.sections.extend(container::sections(bytes));
|
||||
with_assessment(document, assessment)
|
||||
document.sections = assemble(analysis, bytes, assessment);
|
||||
document
|
||||
}
|
||||
|
||||
/// Inserts the AI assessment section when present.
|
||||
/// Assembles the report body: authored narrative then evidence appendix.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `document` - Report document.
|
||||
/// * `analysis` - Completed analysis.
|
||||
/// * `bytes` - Raw target bytes.
|
||||
/// * `assessment` - AI analyst narrative, when available.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The document.
|
||||
fn with_assessment(mut document: Document, assessment: Option<&str>) -> Document {
|
||||
/// The ordered report sections.
|
||||
fn assemble(analysis: &Analysis, bytes: &[u8], assessment: Option<&str>) -> Vec<Section> {
|
||||
let mut sections = Vec::new();
|
||||
if let Some(text) = assessment {
|
||||
document.sections.insert(1, assessment_section(text));
|
||||
sections.push(assessment_section(text));
|
||||
sections.push(appendix_heading());
|
||||
}
|
||||
document
|
||||
sections.extend(evidence(analysis, bytes));
|
||||
sections
|
||||
}
|
||||
|
||||
/// Builds the deterministic evidence sections.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `analysis` - Completed analysis.
|
||||
/// * `bytes` - Raw target bytes.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The evidence sections.
|
||||
fn evidence(analysis: &Analysis, bytes: &[u8]) -> Vec<Section> {
|
||||
let mut sections: Vec<Section> = SECTIONS.iter().flat_map(|b| b(analysis)).collect();
|
||||
sections.extend(rules::sections(analysis, bytes));
|
||||
sections.extend(crypto::sections(bytes));
|
||||
sections.extend(artifacts::sections(analysis, bytes));
|
||||
sections.extend(container::sections(bytes));
|
||||
sections
|
||||
}
|
||||
|
||||
/// Builds the evidence appendix heading.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The heading section.
|
||||
fn appendix_heading() -> Section {
|
||||
let note = text::paragraph(APPENDIX_NOTE);
|
||||
Section::new("Evidence Appendix").with(note)
|
||||
}
|
||||
|
||||
/// Describes the target on the cover.
|
||||
@@ -419,6 +456,16 @@ mod tests {
|
||||
// Import all parent module items
|
||||
use super::*;
|
||||
|
||||
/// Verifies that an AI assessment leads the report and the evidence follows.
|
||||
#[test]
|
||||
fn test_assessment_leads_report() {
|
||||
let analysis = rea_engine::analyze("blob.bin", b"plain text");
|
||||
let doc = build(&analysis, &[], Some("## BLUF\n\nThe target is a blob."));
|
||||
let first = doc.sections.first().map(|s| s.heading.clone());
|
||||
assert_eq!(first.as_deref(), Some("Autonomous Security Assessment"));
|
||||
assert!(doc.sections.iter().any(|s| s.heading == "Evidence Appendix"));
|
||||
}
|
||||
|
||||
/// Verifies that unknown data still yields a complete report.
|
||||
#[test]
|
||||
fn test_build_unknown_data() {
|
||||
|
||||
@@ -152,6 +152,8 @@ fn inspect_tools() -> Value {
|
||||
fn advanced_tools() -> Value {
|
||||
json!([
|
||||
{"name": "search_strings", "description": "Searches extracted strings and indicators.", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}},
|
||||
{"name": "disasm_function", "description": "Disassembles a function with its callers, callees, imports, and referenced strings.", "inputSchema": {"type": "object", "properties": {"address": {"type": "string"}}}},
|
||||
{"name": "classify_regions", "description": "Segments the target by entropy and classifies each region.", "inputSchema": {"type": "object"}},
|
||||
{"name": "analyze_firmware", "description": "Performs SquashFS rootfs triage and security artifact hunting.", "inputSchema": {"type": "object"}},
|
||||
{"name": "analyze_javascript", "description": "Unpacks Electron ASAR archive and extracts IPC channels and secrets.", "inputSchema": {"type": "object"}},
|
||||
{"name": "generate_dossier", "description": "Generates complete nation-state artifact dossier beside target.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}
|
||||
@@ -397,6 +399,8 @@ fn emulation_tool_name(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"emulate" => Some("emulate"),
|
||||
"unpack" => Some("unpack"),
|
||||
"disasm_function" => Some("disasm"),
|
||||
"classify_regions" => Some("regions"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1390,7 +1394,7 @@ mod tests {
|
||||
fn test_tools_catalog_count() {
|
||||
let tools = tool_catalog();
|
||||
assert!(tools.is_array());
|
||||
assert_eq!(tools.as_array().map_or(0, Vec::len), 31);
|
||||
assert_eq!(tools.as_array().map_or(0, Vec::len), 33);
|
||||
}
|
||||
|
||||
/// Verifies runtime tool handlers reject empty sessions.
|
||||
|
||||
+125
-1
@@ -38,6 +38,9 @@ const EMU_BUDGET: u64 = 20_000;
|
||||
/// Maximum findings or rows returned by one tool.
|
||||
const LIMIT: usize = 40;
|
||||
|
||||
/// Maximum instruction lines returned by the `disasm` tool.
|
||||
const DISASM_LIMIT: usize = 400;
|
||||
|
||||
/// Analysis and bytes available to agent tools.
|
||||
pub struct ToolContext<'a> {
|
||||
/// Completed engine analysis.
|
||||
@@ -64,8 +67,9 @@ const DIRECT_TOOLS: [(&str, DirectTool); 6] = [
|
||||
];
|
||||
|
||||
/// Tools that consume a text argument.
|
||||
const ARG_TOOLS: [(&str, ArgTool); 6] = [
|
||||
const ARG_TOOLS: [(&str, ArgTool); 7] = [
|
||||
("decompile", decompile_at),
|
||||
("disasm", disasm_function),
|
||||
("strings", find_strings),
|
||||
("emulate", emulate_at),
|
||||
("read", read_bytes),
|
||||
@@ -469,6 +473,126 @@ fn find_function<'a>(analysis: &'a Analysis, target: &str) -> Option<&'a Functio
|
||||
code.functions.values().find(|func| matches_function(func, target, address))
|
||||
}
|
||||
|
||||
/// Disassembles a function with its call graph and referenced strings.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `ctx` - Tool context.
|
||||
/// * `target` - Function name or hex address.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The annotated function listing or an error message.
|
||||
fn disasm_function(ctx: &ToolContext<'_>, target: &str) -> String {
|
||||
let Some(func) = find_function(ctx.analysis, target) else {
|
||||
return format!("no function matching '{target}'");
|
||||
};
|
||||
let mut out = function_header(func);
|
||||
append_call_graph(&mut out, ctx.analysis, func);
|
||||
append_lines(&mut out, func);
|
||||
out
|
||||
}
|
||||
|
||||
/// Formats a function header with its name and size.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `func` - Function to describe.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The header line.
|
||||
fn function_header(func: &Function) -> String {
|
||||
let name = func.name.as_deref().unwrap_or("?");
|
||||
let imports: Vec<String> = func.imports.iter().cloned().collect();
|
||||
let calls = join_or(&imports, "none");
|
||||
format!(
|
||||
"FUNCTION {name} at {:#x}: {} instructions; imports [{calls}]\n",
|
||||
func.address, func.instructions
|
||||
)
|
||||
}
|
||||
|
||||
/// Appends the function's callees and callers.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `out` - Destination buffer.
|
||||
/// * `analysis` - Completed analysis.
|
||||
/// * `func` - Function to describe.
|
||||
fn append_call_graph(out: &mut String, analysis: &Analysis, func: &Function) {
|
||||
let targets: Vec<String> = func.calls.iter().map(|a| labeled(analysis, *a)).collect();
|
||||
let callers = callers_of(analysis, func.address);
|
||||
let _ = writeln!(out, "CALLEES: {}", join_or(&targets, "none"));
|
||||
let _ = writeln!(out, "CALLERS: {}", join_or(&callers, "none"));
|
||||
}
|
||||
|
||||
/// Appends the annotated instruction lines of a function.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `out` - Destination buffer.
|
||||
/// * `func` - Function to list.
|
||||
fn append_lines(out: &mut String, func: &Function) {
|
||||
for line in func.lines.iter().take(DISASM_LIMIT) {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" {:#x}: {} ; {}",
|
||||
line.address, line.text, line.discussion
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a call target to a function name or address.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `analysis` - Completed analysis.
|
||||
/// * `address` - Target address.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The label text.
|
||||
fn labeled(analysis: &Analysis, address: u64) -> String {
|
||||
let named = analysis.disassembly.as_ref().and_then(|c| c.functions.get(&address));
|
||||
named.and_then(|f| f.name.clone()).unwrap_or_else(|| format!("{address:#x}"))
|
||||
}
|
||||
|
||||
/// Collects the callers of a function.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `analysis` - Completed analysis.
|
||||
/// * `target` - Callee address.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The caller labels.
|
||||
fn callers_of(analysis: &Analysis, target: u64) -> Vec<String> {
|
||||
let Some(code) = &analysis.disassembly else {
|
||||
return Vec::new();
|
||||
};
|
||||
let calls = code.functions.values().filter(|f| f.calls.contains(&target));
|
||||
calls.map(|f| labeled(analysis, f.address)).collect()
|
||||
}
|
||||
|
||||
/// Joins items or returns a placeholder when empty.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `items` - Items to join.
|
||||
/// * `empty` - Placeholder for the empty case.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The joined text.
|
||||
fn join_or(items: &[String], empty: &str) -> String {
|
||||
if items.is_empty() {
|
||||
empty.to_string()
|
||||
} else {
|
||||
items.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reports whether a function matches a name or address.
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
Reference in new issue
Block a user