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:
Kevin Thomas committed 2026-10-14 14:32:36 -04:00
1 parent a04127f8d3
commit d1937c80a0
11 files changed
+1075 -246

No files matched your search

+8
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+40 -1
View File
@@ -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.
---
+9 -31
View File
@@ -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
+151 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
}
+59 -12
View File
@@ -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() {
+5 -1
View File
@@ -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
View File
@@ -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