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