diff --git a/CONVENTIONS.md b/CONVENTIONS.md
index 8590012..5553e68 100644
--- a/CONVENTIONS.md
+++ b/CONVENTIONS.md
@@ -70,5 +70,13 @@ Only generated build-time output under `OUT_DIR` is exempt from the custom check
+# 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`.
+
+
+
# License
[MIT License](https://github.com/mytechnotalent/rea/blob/main/LICENSE)
diff --git a/Cargo.lock b/Cargo.lock
index 2024c1d..774d3f7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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",
diff --git a/Cargo.toml b/Cargo.toml
index f346f1b..c246c99 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/PLAN.md b/PLAN.md
index cd216e0..5c47824 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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.
---
diff --git a/README.md b/README.md
index 89de6b2..39031ab 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-# 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
-# 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.
-
-
+- **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
diff --git a/crates/rea-engine/src/decompiler/ir.rs b/crates/rea-engine/src/decompiler/ir.rs
index 0b6666e..8436f09 100644
--- a/crates/rea-engine/src/decompiler/ir.rs
+++ b/crates/rea-engine/src/decompiler/ir.rs
@@ -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 {
+ 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), 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() {
diff --git a/crates/rea/src/agent.rs b/crates/rea/src/agent.rs
index 4b8bdaf..67afbb8 100644
--- a/crates/rea/src/agent.rs
+++ b/crates/rea/src/agent.rs
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
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 {
- 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 {
+ 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 {
+ 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- {
+ 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,
/// Consecutive turns that produced no new tool call.
stale: usize,
+
+ /// Ordered model chain, primary first.
+ models: Vec,
+
+ /// 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) -> 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 {
+ 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 {
+ 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) -> 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 {
+ 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 {
+ 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 {
+ 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 {
+ fn run(&mut self, key: &str, ctx: &ToolContext<'_>, deadline: Instant) -> Option {
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 {
- eprintln!("rea: agent turn {turn}");
- let message = chat(key, model, &self.messages)?;
+ fn turn(&mut self, turn: usize, key: &str, ctx: &ToolContext<'_>) -> Option {
+ 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 {
+ fn finalize(&mut self, key: &str) -> Option {
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 {
- 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 {
+/// The draft report, or `None` when the reply is empty.
+fn final_report(message: &serde_json::Value) -> Option {
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 {
///
/// # 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 {
- 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 {
+ 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 {
/// # 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 {
/// # Returns
///
/// The final report, or `None`.
-fn run_loop(
- key: &str,
- model: &str,
- ctx: &ToolContext<'_>,
- evidence: &str,
- corpus: &str,
-) -> Option {
- let mut session = Session::new(evidence, corpus);
+fn run_loop(key: &str, ctx: &ToolContext<'_>, evidence: &str, corpus: &str) -> Option {
+ 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 {
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) -> Option {
+ 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);
+ }
}
diff --git a/crates/rea/src/compose/code.rs b/crates/rea/src/compose/code.rs
index 66c31fd..dc4f49c 100644
--- a/crates/rea/src/compose/code.rs
+++ b/crates/rea/src/compose/code.rs
@@ -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-.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-.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,
) -> 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 = func.calls.iter().map(|a| code.name_of(*a)).collect();
+ let imports: Vec = 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 {
+ 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 {
- let mut lines: Vec = func.lines.iter().take(RAW_CAP).map(format_line).collect();
- if func.lines.len() > RAW_CAP {
- lines.push(format!(
- "... {} more instructions; see re-.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> = func.data_pools.iter().map(data_row).collect();
+ let rows: Vec> = 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");
}
}
diff --git a/crates/rea/src/compose/mod.rs b/crates/rea/src/compose/mod.rs
index 5c48568..bdaeeaa 100644
--- a/crates/rea/src/compose/mod.rs
+++ b/crates/rea/src/compose/mod.rs
@@ -105,6 +105,11 @@ const SECTIONS: [fn(&Analysis) -> Vec; 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 {
+ 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 {
+ let mut sections: Vec = 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() {
diff --git a/crates/rea/src/mcp.rs b/crates/rea/src/mcp.rs
index fe36497..42783c1 100644
--- a/crates/rea/src/mcp.rs
+++ b/crates/rea/src/mcp.rs
@@ -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.
diff --git a/crates/rea/src/tools.rs b/crates/rea/src/tools.rs
index e4b8341..14e248f 100644
--- a/crates/rea/src/tools.rs
+++ b/crates/rea/src/tools.rs
@@ -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 = 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 = 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 {
+ 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