NeuroSploit v3.4.0 — Rust multi-model harness + Axum dashboard

New cargo workspace `neurosploit-rs/` (single `neurosploit` binary):

harness crate:
- models.rs: 11 OpenAI-compatible providers / 31 models (Claude, GPT, Grok,
  NVIDIA NIM, DeepSeek, Mistral, Qwen, Groq, Together, OpenRouter, Ollama)
- pool.rs: ModelPool with bounded concurrency, provider failover, and N-model
  validator voting (the panel doubles as the jury)
- agents.rs: loads the existing agents_md/ library (213 agents)
- pipeline.rs: recon → parallel exploit (semaphore-bounded) → N-model
  adversarial vote → score; streams live progress over a channel
- report.rs: HTML report
- tokio + reqwest(rustls); offline mode runs the pipeline without API keys

app binary:
- clap CLI: serve | run | agents | models  (run supports --model x N, --vote-n,
  --max-agents, --offline)
- axum web dashboard with multi-model panel, live console, findings, agent
  browser, embedded report; single binary serves the SPA (no npm/build)

Verified: cargo build clean; agents/models/offline-run CLI; server endpoints
(/api/info, /api/run lifecycle, /report); dashboard + live run in Playwright.

Docs: README v3.4.0 callout + RELEASE.md notes. target/ gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 4.8 committed 2026-06-21 19:58:43 -03:00
1 parent a5badefc29
commit 56d3f0c723
18 files changed
+3268

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
[package]
name = "neurosploit-harness"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "harness"
path = "src/lib.rs"
[dependencies]
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
reqwest.workspace = true
anyhow.workspace = true
futures.workspace = true
walkdir = "2"
regex = "1"
@@ -0,0 +1,77 @@
use regex::Regex;
use serde::Serialize;
use std::path::Path;
use walkdir::WalkDir;
/// One markdown specialist/meta agent.
#[derive(Clone, Debug, Serialize)]
pub struct Agent {
pub name: String,
pub title: String,
pub cwe: String,
pub kind: String, // "vuln" | "meta"
#[serde(skip)]
pub system: String,
#[serde(skip)]
pub user: String,
}
/// The loaded `agents_md/` library.
#[derive(Default)]
pub struct Library {
pub vulns: Vec<Agent>,
pub meta: Vec<Agent>,
}
impl Library {
pub fn total(&self) -> usize {
self.vulns.len() + self.meta.len()
}
}
/// Load `<base>/agents_md/{vulns,meta}/*.md`.
pub fn load(base: &Path) -> Library {
let root = base.join("agents_md");
Library {
vulns: load_dir(&root.join("vulns"), "vuln"),
meta: load_dir(&root.join("meta"), "meta"),
}
}
fn load_dir(dir: &Path, kind: &str) -> Vec<Agent> {
let title_re = Regex::new(r"(?m)^#\s+(.+?)\s*$").unwrap();
let cwe_re = Regex::new(r"CWE-\d+").unwrap();
let user_re = Regex::new(r"(?s)##\s*User Prompt\s*\n(.*?)(?:\n##\s|\z)").unwrap();
let sys_re = Regex::new(r"(?s)##\s*System Prompt\s*\n(.*?)(?:\n##\s|\z)").unwrap();
let mut out = Vec::new();
if !dir.is_dir() {
return out;
}
for entry in WalkDir::new(dir).max_depth(1).into_iter().flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let text = std::fs::read_to_string(path).unwrap_or_default();
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();
let title = title_re
.captures(&text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
.unwrap_or_else(|| name.clone());
let cwe = cwe_re.find(&text).map(|m| m.as_str().to_string()).unwrap_or_default();
let user = user_re
.captures(&text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
.unwrap_or_default();
let system = sys_re
.captures(&text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().trim().to_string())
.unwrap_or_default();
out.push(Agent { name, title, cwe, kind: kind.to_string(), system, user });
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
+20
View File
@@ -0,0 +1,20 @@
//! NeuroSploit v3.4.0 harness — a robust multi-model runtime for the
//! markdown-driven autonomous pentest engine.
//!
//! The harness loads the `agents_md/` library, drives a *pool* of LLM models
//! (any OpenAI-compatible provider) with concurrency + provider failover, runs
//! the specialist agents in parallel, then validates every candidate finding by
//! **N-model voting** before scoring and reporting.
pub mod agents;
pub mod models;
pub mod pipeline;
pub mod pool;
pub mod report;
pub mod types;
pub use agents::{Agent, Library};
pub use models::{provider_for, providers, ChatClient, ModelRef, Provider};
pub use pipeline::run;
pub use pool::ModelPool;
pub use types::{Finding, RunConfig};
+134
View File
@@ -0,0 +1,134 @@
use anyhow::{anyhow, Result};
use serde::Serialize;
use std::time::Duration;
/// A model provider exposing an OpenAI-compatible `/chat/completions` endpoint.
#[derive(Clone, Debug, Serialize)]
pub struct Provider {
pub key: &'static str,
pub label: &'static str,
pub base_url: &'static str,
pub env_key: &'static str,
/// "cli" (also drivable by an agentic CLI) | "api"
pub kind: &'static str,
pub models: Vec<&'static str>,
}
/// The full provider registry. Every entry speaks the OpenAI chat schema
/// (Anthropic, xAI, NVIDIA NIM, DeepSeek, Mistral, Qwen, Groq, Together,
/// OpenRouter, Gemini-compat, Ollama).
pub fn providers() -> Vec<Provider> {
vec![
Provider { key: "anthropic", label: "Anthropic Claude", base_url: "https://api.anthropic.com/v1", env_key: "ANTHROPIC_API_KEY", kind: "cli",
models: vec!["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"] },
Provider { key: "openai", label: "OpenAI", base_url: "https://api.openai.com/v1", env_key: "OPENAI_API_KEY", kind: "cli",
models: vec!["gpt-5.1", "o4"] },
Provider { key: "xai", label: "xAI Grok", base_url: "https://api.x.ai/v1", env_key: "XAI_API_KEY", kind: "cli",
models: vec!["grok-4", "grok-4-fast"] },
Provider { key: "nvidia_nim", label: "NVIDIA NIM", base_url: "https://integrate.api.nvidia.com/v1", env_key: "NVIDIA_NIM_API_KEY", kind: "api",
models: vec!["nvidia/llama-3.3-nemotron-super-49b-v1", "deepseek-ai/deepseek-r1", "qwen/qwen2.5-coder-32b-instruct"] },
Provider { key: "deepseek", label: "DeepSeek", base_url: "https://api.deepseek.com/v1", env_key: "DEEPSEEK_API_KEY", kind: "api",
models: vec!["deepseek-reasoner", "deepseek-chat"] },
Provider { key: "mistral", label: "Mistral", base_url: "https://api.mistral.ai/v1", env_key: "MISTRAL_API_KEY", kind: "api",
models: vec!["mistral-large-latest", "codestral-latest"] },
Provider { key: "qwen", label: "Qwen (DashScope)", base_url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", env_key: "DASHSCOPE_API_KEY", kind: "api",
models: vec!["qwen-max", "qwen2.5-coder-32b-instruct", "qwq-plus"] },
Provider { key: "groq", label: "Groq", base_url: "https://api.groq.com/openai/v1", env_key: "GROQ_API_KEY", kind: "api",
models: vec!["llama-3.3-70b-versatile", "qwen-2.5-coder-32b"] },
Provider { key: "together", label: "Together AI", base_url: "https://api.together.xyz/v1", env_key: "TOGETHER_API_KEY", kind: "api",
models: vec!["Qwen/Qwen2.5-Coder-32B-Instruct", "deepseek-ai/DeepSeek-R1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"] },
Provider { key: "openrouter", label: "OpenRouter", base_url: "https://openrouter.ai/api/v1", env_key: "OPENROUTER_API_KEY", kind: "api",
models: vec!["anthropic/claude-opus-4-8", "qwen/qwen-2.5-coder-32b-instruct", "deepseek/deepseek-r1", "meta-llama/llama-3.3-70b-instruct"] },
Provider { key: "ollama", label: "Ollama (local)", base_url: "http://localhost:11434/v1", env_key: "OLLAMA_API_KEY", kind: "api",
models: vec!["qwen2.5-coder:32b", "qwq:32b", "deepseek-r1:32b", "llama3.3:70b"] },
]
}
pub fn provider_for(key: &str) -> Option<Provider> {
providers().into_iter().find(|p| p.key == key)
}
/// A `provider:model` selection.
#[derive(Clone, Debug)]
pub struct ModelRef {
pub provider: String,
pub model: String,
}
impl ModelRef {
pub fn parse(s: &str) -> ModelRef {
match s.split_once(':') {
Some((p, m)) => ModelRef { provider: p.to_string(), model: m.to_string() },
None => ModelRef { provider: "anthropic".into(), model: s.to_string() },
}
}
pub fn label(&self) -> String {
format!("{}:{}", self.provider, self.model)
}
}
/// OpenAI-compatible chat client shared across the model pool.
#[derive(Clone)]
pub struct ChatClient {
http: reqwest::Client,
}
impl ChatClient {
pub fn new() -> Self {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
ChatClient { http }
}
/// One chat completion. Errors (missing key, network, non-2xx) propagate so
/// the pool can fail over to the next candidate model.
pub async fn chat(&self, m: &ModelRef, system: &str, user: &str) -> Result<String> {
let p = provider_for(&m.provider)
.ok_or_else(|| anyhow!("unknown provider '{}'", m.provider))?;
let key = std::env::var(p.env_key).unwrap_or_default();
if key.is_empty() && p.key != "ollama" {
return Err(anyhow!("no API key ({}) for provider '{}'", p.env_key, p.key));
}
let url = format!("{}/chat/completions", p.base_url.trim_end_matches('/'));
let body = serde_json::json!({
"model": m.model,
"max_tokens": 4096,
"temperature": 0.2,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user}
]
});
let mut req = self.http.post(&url).json(&body);
if !key.is_empty() {
req = req.bearer_auth(&key);
}
let resp = req.send().await?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(anyhow!("{} returned {}: {}", p.key, status, truncate(&text, 200)));
}
let v: serde_json::Value = serde_json::from_str(&text)?;
let content = v["choices"][0]["message"]["content"]
.as_str()
.ok_or_else(|| anyhow!("no content in response"))?;
Ok(content.to_string())
}
}
impl Default for ChatClient {
fn default() -> Self {
Self::new()
}
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
s.to_string()
} else {
format!("{}…", &s[..n])
}
}
@@ -0,0 +1,161 @@
use crate::agents::{Agent, Library};
use crate::pool::ModelPool;
use crate::types::{Finding, RunConfig};
use futures::stream::{self, StreamExt};
use serde::Serialize;
use tokio::sync::mpsc::Sender;
/// Result of an engagement run.
#[derive(Default, Serialize)]
pub struct RunOutput {
pub findings: Vec<Finding>,
pub agents_ran: Vec<String>,
pub candidates: usize,
}
const RECON_SYS: &str = "You are a web recon specialist. Map the target's attack surface and reply with a compact JSON object (tech, endpoints, auth, apis, ai_features). No prose.";
const VOTE_SYS: &str = "You are an adversarial security validator. Decide if the candidate finding is a REAL, reproducible, exploitable vulnerability with proof. Reply with JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}. Default to rejected when uncertain.";
/// Run the full harness pipeline, streaming human-readable progress over `tx`.
pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
let _ = tx
.send(format!(
"Loaded {} agents ({} vuln / {} meta) · models: {} · vote_n={} · concurrency={}",
lib.total(),
lib.vulns.len(),
lib.meta.len(),
pool.candidates.iter().map(|m| m.label()).collect::<Vec<_>>().join(", "),
cfg.vote_n,
cfg.concurrency,
))
.await;
// ---- 1. Recon -------------------------------------------------------
let recon = if cfg.offline {
let _ = tx.send("recon: offline mode — skipping model calls".into()).await;
"{}".to_string()
} else {
match pool.complete(RECON_SYS, &format!("Target: {}", cfg.target)).await {
Ok((m, t)) => {
let _ = tx.send(format!("recon complete via {}", m.label())).await;
t
}
Err(e) => {
let _ = tx.send(format!("recon failed ({e}) — continuing with empty recon")).await;
"{}".to_string()
}
}
};
// ---- 2. Select agents ----------------------------------------------
let cap = if cfg.max_agents > 0 { cfg.max_agents } else { lib.vulns.len() };
let selected: Vec<Agent> = lib.vulns.iter().take(cap).cloned().collect();
let _ = tx.send(format!("selected {} specialist agents", selected.len())).await;
if cfg.offline {
let _ = tx.send("offline: no exploitation performed (provide API keys to run live)".into()).await;
return RunOutput {
findings: vec![],
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),
candidates: 0,
};
}
// ---- 3. Exploit (parallel, bounded by the pool semaphore) ----------
let target = cfg.target.clone();
let candidates: Vec<Finding> = stream::iter(selected.iter().cloned())
.map(|ag| {
let target = target.clone();
let recon = recon.clone();
let txc = tx.clone();
async move {
let user = format!(
"{}\n\nReply ONLY with a JSON array of confirmed findings (may be empty []). \
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.",
ag.user.replace("{target}", &target).replace("{recon_json}", &recon)
);
match pool.complete(&ag.system, &user).await {
Ok((m, text)) => {
let f = extract_findings(&text, &ag.name);
let _ = txc
.send(format!("exploit {} via {} → {} candidate(s)", ag.name, m.label(), f.len()))
.await;
f
}
Err(e) => {
let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await;
vec![]
}
}
}
})
.buffer_unordered(cfg.concurrency)
.collect::<Vec<Vec<Finding>>>()
.await
.into_iter()
.flatten()
.collect();
let _ = tx.send(format!("{} candidate finding(s) — validating by {}-model vote", candidates.len(), cfg.vote_n)).await;
// ---- 4. Validate by N-model voting ---------------------------------
let vote_n = cfg.vote_n;
let validated: Vec<Finding> = stream::iter(candidates.into_iter())
.map(|mut f| {
let txc = tx.clone();
async move {
let q = format!(
"Finding: {} | severity {} | {} | endpoint {} | payload {} | evidence {}",
f.title, f.severity, f.cwe, f.endpoint, f.payload, f.evidence
);
let (yes, total) = pool.vote(VOTE_SYS, &q, vote_n).await;
f.validated = total > 0 && yes * 2 >= total;
f.votes = format!("{yes}/{total}");
if f.confidence == 0.0 && total > 0 {
f.confidence = yes as f64 / total as f64;
}
let _ = txc
.send(format!("vote {} → {} ({})", f.title, if f.validated { "CONFIRMED" } else { "rejected" }, f.votes))
.await;
f
}
})
.buffer_unordered(cfg.concurrency)
.collect::<Vec<Finding>>()
.await;
let candidates = validated.len();
let findings: Vec<Finding> = validated.into_iter().filter(|f| f.validated).collect();
let _ = tx.send(format!("{} validated finding(s)", findings.len())).await;
RunOutput {
findings,
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),
candidates,
}
}
/// Pull a JSON array (or object) of findings out of a model's reply.
fn extract_findings(text: &str, agent: &str) -> Vec<Finding> {
let slice = match (text.find('['), text.rfind(']')) {
(Some(a), Some(b)) if b > a => &text[a..=b],
_ => match (text.find('{'), text.rfind('}')) {
(Some(a), Some(b)) if b > a => &text[a..=b],
_ => return vec![],
},
};
let mut out: Vec<Finding> = if let Ok(v) = serde_json::from_str::<Vec<Finding>>(slice) {
v
} else if let Ok(one) = serde_json::from_str::<Finding>(slice) {
vec![one]
} else {
return vec![];
};
for f in out.iter_mut() {
f.agent = agent.to_string();
if f.id.is_empty() {
f.id = format!("{}-{}", agent, &f.title.chars().take(12).collect::<String>());
}
}
out
}
+68
View File
@@ -0,0 +1,68 @@
use crate::models::{ChatClient, ModelRef};
use anyhow::{anyhow, Result};
use std::sync::Arc;
use tokio::sync::Semaphore;
/// A pool of candidate models with a global concurrency cap and provider
/// failover. The same panel of models is reused for validator voting.
pub struct ModelPool {
client: ChatClient,
sem: Arc<Semaphore>,
pub candidates: Vec<ModelRef>,
}
impl ModelPool {
pub fn new(models: Vec<ModelRef>, concurrency: usize) -> Self {
let concurrency = concurrency.max(1);
ModelPool {
client: ChatClient::new(),
sem: Arc::new(Semaphore::new(concurrency)),
candidates: if models.is_empty() {
vec![ModelRef::parse("anthropic:claude-opus-4-8")]
} else {
models
},
}
}
/// Complete a prompt, trying each candidate model until one succeeds.
/// Returns the model that answered and its text.
pub async fn complete(&self, system: &str, user: &str) -> Result<(ModelRef, String)> {
let _permit = self.sem.acquire().await.expect("semaphore closed");
let mut last = anyhow!("no candidate models");
for m in &self.candidates {
match self.client.chat(m, system, user).await {
Ok(text) => return Ok((m.clone(), text)),
Err(e) => last = e,
}
}
Err(last)
}
/// Ask up to `n` distinct models the same yes/no validation question and
/// return (confirmations, total_votes). A model answering "yes"/"confirmed"
/// counts as a confirmation. Used to cut false positives.
pub async fn vote(&self, system: &str, user: &str, n: usize) -> (usize, usize) {
let panel: Vec<ModelRef> = self.candidates.iter().take(n.max(1)).cloned().collect();
let mut confirmed = 0usize;
let mut total = 0usize;
for m in &panel {
let _permit = match self.sem.acquire().await {
Ok(p) => p,
Err(_) => break,
};
if let Ok(text) = self.client.chat(m, system, user).await {
total += 1;
let t = text.to_lowercase();
if t.contains("\"verdict\": \"confirmed\"")
|| t.trim_start().starts_with("yes")
|| t.contains("confirmed: true")
|| t.contains("is_real\": true")
{
confirmed += 1;
}
}
}
(confirmed, total)
}
}
@@ -0,0 +1,82 @@
use crate::types::Finding;
fn sev_rank(s: &str) -> u8 {
match s {
"Critical" => 0,
"High" => 1,
"Medium" => 2,
"Low" => 3,
_ => 4,
}
}
fn sev_color(s: &str) -> &'static str {
match s {
"Critical" => "#c0392b",
"High" => "#e67e22",
"Medium" => "#f1c40f",
"Low" => "#3498db",
_ => "#7f8c8d",
}
}
fn esc(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
/// Render an HTML report for the validated findings.
pub fn html(target: &str, findings: &[Finding]) -> String {
let mut sorted = findings.to_vec();
sorted.sort_by_key(|f| sev_rank(&f.severity));
let mut counts: std::collections::BTreeMap<&str, usize> = Default::default();
for f in &sorted {
*counts.entry(f.severity.as_str()).or_default() += 1;
}
let chips: String = if counts.is_empty() {
"<span class=chip style=background:#27ae60>No validated findings</span>".into()
} else {
counts
.iter()
.map(|(s, n)| format!("<span class=chip style=background:{}>{}: {}</span>", sev_color(s), s, n))
.collect()
};
let rows: String = sorted
.iter()
.enumerate()
.map(|(i, f)| {
format!(
"<section class=finding><h3><span class=sev style=background:{}>{}</span> {}. {}</h3>\
<div class=m>{} · {} · CVSS {} · votes {} · conf {:.2}</div>\
<div class=m>Endpoint: {}</div>\
<h4>Payload</h4><pre>{}</pre><h4>Evidence</h4><pre>{}</pre>\
<h4>Impact</h4><p>{}</p><h4>Remediation</h4><p>{}</p></section>",
sev_color(&f.severity), esc(&f.severity), i + 1, esc(&f.title),
esc(&f.agent), esc(&f.cwe), esc(&f.cvss), esc(&f.votes), f.confidence,
esc(&f.endpoint), esc(&f.payload), esc(&f.evidence), esc(&f.impact), esc(&f.remediation),
)
})
.collect();
let body = if rows.is_empty() {
"<p><em>No validated findings were produced for this engagement.</em></p>".to_string()
} else {
rows
};
format!(
"<!DOCTYPE html><html><head><meta charset=utf-8><title>NeuroSploit Report — {t}</title><style>\
body{{font:14px/1.6 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;max-width:860px;margin:40px auto;padding:0 24px}}\
h1{{margin:0}}.meta{{color:#666;margin:4px 0 18px}}.chip{{color:#fff;border-radius:999px;padding:4px 12px;margin-right:8px;font-size:13px;font-weight:600}}\
.finding{{border:1px solid #e3e3e3;border-radius:12px;padding:16px 20px;margin:16px 0}}.finding h3{{margin:0 0 8px;font-size:16px}}\
.sev{{color:#fff;border-radius:6px;padding:2px 8px;font-size:12px;margin-right:8px}}.m{{color:#666;font-size:12px}}\
pre{{background:#0f1117;color:#dfe6f3;padding:11px;border-radius:8px;overflow:auto;font-size:12.5px}}\
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
<div class=meta>Target: <b>{t}</b> · v3.4.0 Rust harness · multi-model validated</div>\
<div>{chips}</div><h2>Findings ({n})</h2>{body}\
<p class=meta>Authorized testing only. Findings confirmed by multi-model adversarial voting.</p></body></html>",
t = esc(target), chips = chips, n = sorted.len(), body = body,
)
}
@@ -0,0 +1,93 @@
use serde::{Deserialize, Serialize};
/// A validated (or candidate) security finding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub id: String,
pub agent: String,
pub title: String,
pub severity: String,
#[serde(default)]
pub cwe: String,
#[serde(default)]
pub cvss: String,
#[serde(default)]
pub endpoint: String,
#[serde(default)]
pub payload: String,
#[serde(default)]
pub evidence: String,
#[serde(default)]
pub impact: String,
#[serde(default)]
pub remediation: String,
#[serde(default)]
pub confidence: f64,
#[serde(default)]
pub validated: bool,
/// Per-model vote summary, e.g. "3/4 confirmed".
#[serde(default)]
pub votes: String,
}
impl Default for Finding {
fn default() -> Self {
Finding {
id: String::new(),
agent: String::new(),
title: String::new(),
severity: "Info".into(),
cwe: String::new(),
cvss: String::new(),
endpoint: String::new(),
payload: String::new(),
evidence: String::new(),
impact: String::new(),
remediation: String::new(),
confidence: 0.0,
validated: false,
votes: String::new(),
}
}
}
/// Configuration for a single engagement run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunConfig {
pub target: String,
/// Model references in `provider:model` form. The first is primary; the
/// rest are failover candidates and also the voting panel.
pub models: Vec<String>,
/// Number of models that cross-check each candidate finding.
#[serde(default = "default_vote")]
pub vote_n: usize,
/// Max concurrent model calls.
#[serde(default = "default_concurrency")]
pub concurrency: usize,
/// Cap on specialist agents to run (0 = all).
#[serde(default)]
pub max_agents: usize,
/// Offline mode: exercise the full pipeline without calling any model API.
#[serde(default)]
pub offline: bool,
}
fn default_vote() -> usize {
3
}
fn default_concurrency() -> usize {
8
}
impl RunConfig {
pub fn new(target: impl Into<String>) -> Self {
RunConfig {
target: target.into(),
models: vec!["anthropic:claude-opus-4-8".into()],
vote_n: 3,
concurrency: 8,
max_agents: 0,
offline: false,
}
}
}