v3.4.x: intelligent agent selection, whitebox, recon/code agents, Gemini, artifacts, RL, XBOW GUI

Harness intelligence:
- After recon, the model SELECTS which specialist agents match the target
  (select_agents) — runs the relevant subset, not blindly top-N
- RL reward store (rl.rs): per-agent weights persist to data/rl_state_rs.json,
  reward validated findings (severity-weighted), decay idle, bias next run
- Run artifacts persisted as JSON + MD (recon, exploitation transcript,
  findings, html report) under runs/<target>-<ts>/ for reuse by other AIs

Whitebox mode:
- run_whitebox: walks a repo, builds bounded source context, runs code agents,
  validates by adversarial vote. CLI `whitebox <path>` + web "White-box" mode

Agents: +12 recon (subdomain/tech/js/api/secrets/dns/content/param/waf/cloud/
graphql/osint) and +24 code SAST reviewers (sqli/cmdi/path/ssrf/xss/deser/
secrets/crypto/authz/idor/xxe/redirect/ssti/race/eval/csrf/random/logging/
upload/mass-assign/jwt/cors). Loader gains recon/ + code/ categories → 249 total

Models: +Google Gemini provider (API + gemini CLI subscription); installed_cli_
backends now detects gemini; chat_cli handles gemini/codex/grok + optional
Playwright MCP (.mcp.json) on the subscription path with autonomy flags

GUI: full XBOW-style redesign — sidebar (Operate/Library), topbar status, mode
segment (black-box/white-box), model panel, live console, severity cards,
agent browser with category filters, models view; responsive + aligned

Verified: cargo build --release clean; CLI agents/whitebox; LIVE subscription
run shows model selecting 23→4 agents, RL update, artifacts written; GUI +
white-box toggle in Playwright.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-23 11:39:56 -03:00
parent bf56184912
commit 3ca3f269ee
53 changed files with 3684 additions and 209 deletions
+6 -2
View File
@@ -21,20 +21,24 @@ pub struct Agent {
pub struct Library {
pub vulns: Vec<Agent>,
pub meta: Vec<Agent>,
pub recon: Vec<Agent>,
pub code: Vec<Agent>,
}
impl Library {
pub fn total(&self) -> usize {
self.vulns.len() + self.meta.len()
self.vulns.len() + self.meta.len() + self.recon.len() + self.code.len()
}
}
/// Load `<base>/agents_md/{vulns,meta}/*.md`.
/// Load `<base>/agents_md/{vulns,meta,recon,code}/*.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"),
recon: load_dir(&root.join("recon"), "recon"),
code: load_dir(&root.join("code"), "code"),
}
}
+4 -1
View File
@@ -11,12 +11,15 @@ pub mod models;
pub mod pipeline;
pub mod pool;
pub mod report;
pub mod rl;
pub mod types;
pub use agents::{Agent, Library};
pub use models::{
cli_binary_for, installed_cli_backends, provider_for, providers, ChatClient, ModelRef, Provider,
cli_binary_for, installed_cli_backends, provider_for, providers, write_mcp_config, ChatClient,
ModelRef, Provider,
};
pub use pipeline::{run_whitebox, RunOutput};
pub use pipeline::run;
pub use pool::ModelPool;
pub use types::{Finding, RunConfig};
+47 -5
View File
@@ -28,6 +28,8 @@ pub fn providers() -> Vec<Provider> {
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: "gemini", label: "Google Gemini", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", env_key: "GEMINI_API_KEY", kind: "cli",
models: vec!["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"] },
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",
@@ -124,9 +126,20 @@ impl ChatClient {
impl ChatClient {
/// Complete via a locally-installed **agentic CLI subscription** (Claude
/// Code / Codex / Grok) instead of an API key. This uses the user's logged-in
/// subscription, so no provider key is required.
pub async fn chat_cli(&self, provider: &str, model: &str, system: &str, user: &str) -> Result<String> {
/// Code / Codex / Grok / Gemini) instead of an API key. This uses the user's
/// logged-in subscription, so no provider key is required.
///
/// When `mcp_config` is set (a path to an `.mcp.json`), Claude/Codex run with
/// the MCP servers enabled and tool autonomy, so agents can actually drive
/// **Playwright** (browse, execute JS, screenshot) during execution.
pub async fn chat_cli(
&self,
provider: &str,
model: &str,
system: &str,
user: &str,
mcp_config: Option<&str>,
) -> Result<String> {
let bin = cli_binary_for(provider)
.ok_or_else(|| anyhow!("no CLI/subscription backend for provider '{}'", provider))?;
let prompt = format!("{system}\n\n{user}");
@@ -135,10 +148,24 @@ impl ChatClient {
// Claude Code headless print mode (uses the Claude subscription login).
"claude" => {
cmd.arg("-p").arg("--model").arg(model);
if let Some(mcp) = mcp_config {
cmd.arg("--mcp-config").arg(mcp).arg("--dangerously-skip-permissions");
// Required to allow tool autonomy when running as root.
cmd.env("IS_SANDBOX", "1");
}
}
// Codex non-interactive exec (uses the ChatGPT/Codex login), prompt on stdin.
"codex" => {
cmd.arg("exec").arg("--model").arg(model).arg("-");
cmd.arg("exec").arg("--model").arg(model);
if let Some(mcp) = mcp_config {
cmd.arg("--config").arg(format!("mcp_config_file={mcp}"))
.arg("--dangerously-bypass-approvals-and-sandbox");
}
cmd.arg("-");
}
// Google Gemini CLI (uses the Gemini subscription login).
"gemini" => {
cmd.arg("-m").arg(model);
}
// Grok CLI, prompt on stdin (best-effort flags).
"grok" => {
@@ -166,6 +193,7 @@ pub fn cli_binary_for(provider: &str) -> Option<&'static str> {
"anthropic" => Some("claude"),
"openai" => Some("codex"),
"xai" => Some("grok"),
"gemini" => Some("gemini"),
_ => None,
}
}
@@ -179,7 +207,21 @@ pub fn binary_in_path(name: &str) -> bool {
/// Which subscription CLI backends are installed locally.
pub fn installed_cli_backends() -> Vec<&'static str> {
["claude", "codex", "grok"].into_iter().filter(|b| binary_in_path(b)).collect()
["claude", "codex", "grok", "gemini"].into_iter().filter(|b| binary_in_path(b)).collect()
}
/// Write a Playwright `.mcp.json` into `dir` and return its path, so the agentic
/// CLI can drive a real browser (DOM/JS/network/screenshots) during execution.
pub fn write_mcp_config(dir: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
std::fs::create_dir_all(dir)?;
let path = dir.join(".mcp.json");
let cfg = r#"{
"mcpServers": {
"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"] }
}
}"#;
std::fs::write(&path, cfg)?;
Ok(path)
}
impl Default for ChatClient {
+258 -42
View File
@@ -1,8 +1,11 @@
use crate::agents::{Agent, Library};
use crate::pool::ModelPool;
use crate::rl::{severity_reward, RlState};
use crate::types::{Finding, RunConfig};
use crate::report;
use futures::stream::{self, StreamExt};
use serde::Serialize;
use std::path::{Path, PathBuf};
use tokio::sync::mpsc::Sender;
/// Result of an engagement run.
@@ -11,26 +14,28 @@ pub struct RunOutput {
pub findings: Vec<Finding>,
pub agents_ran: Vec<String>,
pub candidates: usize,
pub recon: String,
/// Paths to persisted artifacts (recon/exploit/findings/report), if any.
pub artifacts: Vec<String>,
}
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.";
const CODE_VOTE_SYS: &str = "You are an adversarial source-code reviewer. Decide if the reported issue is a REAL vulnerability in the provided code (reachable, exploitable, not a false positive). Reply JSON {\"verdict\":\"confirmed\"|\"rejected\",\"reason\":\"...\"}.";
/// Run the full harness pipeline, streaming human-readable progress over `tx`.
/// Black-box web engagement: recon → parallel exploit → N-model vote → report.
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(),
"Loaded {} agents ({} vuln / {} recon / {} code / {} meta) · models: {} · vote_n={} · concurrency={}{}",
lib.total(), lib.vulns.len(), lib.recon.len(), lib.code.len(), lib.meta.len(),
pool.candidates.iter().map(|m| m.label()).collect::<Vec<_>>().join(", "),
cfg.vote_n,
cfg.concurrency,
cfg.vote_n, cfg.concurrency,
if pool.mcp_config.is_some() { " · Playwright MCP ON" } else { "" },
))
.await;
// ---- 1. Recon -------------------------------------------------------
// ---- 1. Recon ------------------------------------------------------
let recon = if cfg.offline {
let _ = tx.send("recon: offline mode — skipping model calls".into()).await;
"{}".to_string()
@@ -47,23 +52,42 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
}
};
// ---- 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;
// ---- 2. Intelligent, RL-ranked agent selection ---------------------
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
let mut ranked: Vec<Agent> = lib.vulns.clone();
ranked.sort_by(|a, b| rl.weight(&b.name).partial_cmp(&rl.weight(&a.name)).unwrap_or(std::cmp::Ordering::Equal));
let cap = if cfg.max_agents > 0 { cfg.max_agents.min(ranked.len()) } else { ranked.len() };
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,
};
let selected: Vec<Agent> = ranked.into_iter().take(cap).collect();
let _ = tx.send(format!("selected {} specialist agents (RL-ranked)", selected.len())).await;
let _ = tx.send("offline: no exploitation performed (provide API keys or --subscription to run live)".into()).await;
let artifacts = persist(&cfg, &recon, "", &[]);
return RunOutput { findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
}
// ---- 3. Exploit (parallel, bounded by the pool semaphore) ----------
// Use the model to pick the agents whose preconditions match the recon —
// the harness reasons about *which* specialists to run, not all of them.
let chosen = select_agents(pool, &recon, &ranked, &tx).await;
let selected: Vec<Agent> = {
let mut sel: Vec<Agent> = if chosen.is_empty() {
ranked.clone()
} else {
ranked.iter().filter(|a| chosen.iter().any(|c| c == &a.name)).cloned().collect()
};
if sel.is_empty() {
sel = ranked.clone();
}
sel.into_iter().take(cap).collect()
};
let _ = tx
.send(format!("intelligently selected {} agent(s) matching recon: {}", selected.len(),
selected.iter().map(|a| a.name.clone()).collect::<Vec<_>>().join(", ")))
.await;
// ---- 3. Exploit (parallel) -----------------------------------------
let target = cfg.target.clone();
let candidates: Vec<Finding> = stream::iter(selected.iter().cloned())
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
.map(|ag| {
let target = target.clone();
let recon = recon.clone();
@@ -77,64 +101,221 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
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
let _ = txc.send(format!("exploit {} via {}{} candidate(s)", ag.name, m.label(), f.len())).await;
(ag.name.clone(), text, f)
}
Err(e) => {
let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await;
vec![]
(ag.name.clone(), format!("ERROR: {e}"), vec![])
}
}
}
})
.buffer_unordered(cfg.concurrency)
.collect::<Vec<Vec<Finding>>>()
.await
.into_iter()
.flatten()
.collect();
.collect()
.await;
let transcript = transcript_of(&raw);
let candidates: Vec<Finding> = raw.iter().flat_map(|(_, _, f)| f.clone()).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 findings = validate(candidates, pool, VOTE_SYS, cfg.vote_n, &tx).await;
finish(cfg, lib, recon, transcript, findings, selected, &mut rl, tx).await
}
/// White-box engagement: analyse a repository's source for vulnerabilities.
pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<String>) -> RunOutput {
let _ = tx.send(format!("WHITEBOX · repo: {} · {} code agents · models: {}", cfg.target, lib.code.len(),
pool.candidates.iter().map(|m| m.label()).collect::<Vec<_>>().join(", "))).await;
let context = collect_repo_context(Path::new(&cfg.target), 200, 120_000);
let bytes = context.len();
let _ = tx.send(format!("collected {} bytes of source context", bytes)).await;
if bytes == 0 {
let _ = tx.send("no readable source found at the given path".into()).await;
}
let mut rl = cfg.rl_path.as_ref().map(|p| RlState::load(Path::new(p))).unwrap_or_default();
let mut ranked: Vec<Agent> = if lib.code.is_empty() { lib.vulns.clone() } else { lib.code.clone() };
ranked.sort_by(|a, b| rl.weight(&b.name).partial_cmp(&rl.weight(&a.name)).unwrap_or(std::cmp::Ordering::Equal));
let cap = if cfg.max_agents > 0 { cfg.max_agents.min(ranked.len()) } else { ranked.len() };
let selected: Vec<Agent> = ranked.into_iter().take(cap).collect();
let _ = tx.send(format!("selected {} code-analysis agents", selected.len())).await;
if cfg.offline || bytes == 0 {
let artifacts = persist(&cfg, "{}", &context, &[]);
return RunOutput { findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts };
}
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
.map(|ag| {
let ctx = context.clone();
let txc = tx.clone();
async move {
let user = format!(
"{}\n\nSOURCE CODE TO REVIEW:\n```\n{}\n```\n\nReply ONLY with a JSON array of findings (may be empty []). \
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}} \
where `endpoint` is the file:line and `evidence` quotes the vulnerable code.",
ag.user.replace("{target}", "the provided repository").replace("{recon_json}", "{}"),
ctx
);
match pool.complete(&ag.system, &user).await {
Ok((m, text)) => {
let f = extract_findings(&text, &ag.name);
let _ = txc.send(format!("analyze {} via {}{} candidate(s)", ag.name, m.label(), f.len())).await;
(ag.name.clone(), text, f)
}
Err(e) => {
let _ = txc.send(format!("analyze {} failed: {e}", ag.name)).await;
(ag.name.clone(), format!("ERROR: {e}"), vec![])
}
}
}
})
.buffer_unordered(cfg.concurrency)
.collect()
.await;
let transcript = transcript_of(&raw);
let candidates: Vec<Finding> = raw.iter().flat_map(|(_, _, f)| f.clone()).collect();
let _ = tx.send(format!("{} candidate finding(s) — validating", candidates.len())).await;
let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
finish(cfg, lib, "{}".into(), transcript, findings, selected, &mut rl, tx).await
}
// --------------------------------------------------------------------------- shared
const SELECT_SYS: &str = "You are a penetration-test orchestrator. Given recon of a target and a catalog of specialist agents, choose ONLY the agents whose preconditions clearly match the target's attack surface. Be selective. Reply with a JSON array of agent names (strings) drawn exactly from the catalog. No prose.";
/// Ask the model which agents to run for this recon. Returns chosen agent names
/// (empty on failure → caller falls back to RL-ranked agents).
async fn select_agents(pool: &ModelPool, recon: &str, catalog: &[Agent], tx: &Sender<String>) -> Vec<String> {
let list = catalog
.iter()
.map(|a| format!("{}{} [{}]", a.name, a.title.replace(" Agent", ""), a.cwe))
.collect::<Vec<_>>()
.join("\n");
let user = format!("RECON:\n{recon}\n\nAGENT CATALOG (name — title [cwe]):\n{list}\n\nReturn a JSON array of agent names to run.");
match pool.complete(SELECT_SYS, &user).await {
Ok((m, text)) => {
let names = parse_string_array(&text);
let _ = tx.send(format!("agent selection via {}{} agent(s) chosen", m.label(), names.len())).await;
names
}
Err(e) => {
let _ = tx.send(format!("agent selection failed ({e}) — falling back to RL ranking")).await;
vec![]
}
}
}
fn parse_string_array(text: &str) -> Vec<String> {
match (text.find('['), text.rfind(']')) {
(Some(a), Some(b)) if b > a => serde_json::from_str::<Vec<String>>(&text[a..=b]).unwrap_or_default(),
_ => vec![],
}
}
async fn validate(candidates: Vec<Finding>, pool: &ModelPool, sys: &str, vote_n: usize, tx: &Sender<String>) -> Vec<Finding> {
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 {}",
"Finding: {} | severity {} | {} | at {} | 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;
let (yes, total) = pool.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;
let _ = txc.send(format!("vote {}{} ({})", f.title, if f.validated { "CONFIRMED" } else { "rejected" }, f.votes)).await;
f
}
})
.buffer_unordered(cfg.concurrency)
.collect::<Vec<Finding>>()
.buffer_unordered(pool.candidates.len().max(2))
.collect()
.await;
validated.into_iter().filter(|f| f.validated).collect()
}
let candidates = validated.len();
let findings: Vec<Finding> = validated.into_iter().filter(|f| f.validated).collect();
async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: String, findings: Vec<Finding>,
selected: Vec<Agent>, rl: &mut RlState, tx: Sender<String>) -> RunOutput {
let _ = tx.send(format!("{} validated finding(s)", findings.len())).await;
// RL update: reward agents that produced validated findings; gently decay idle.
let hit: std::collections::HashMap<&str, f64> = findings.iter().fold(Default::default(), |mut m, f| {
let e = m.entry(f.agent.as_str()).or_insert(0.0);
*e = (*e + severity_reward(&f.severity)).min(1.0);
m
});
for a in &selected {
let r = hit.get(a.name.as_str()).copied().unwrap_or(-0.05);
rl.update(&a.name, r);
}
rl.runs += 1;
if let Some(p) = &cfg.rl_path {
rl.save(Path::new(p));
let _ = tx.send("RL rewards updated".into()).await;
}
let artifacts = persist(&cfg, &recon, &transcript, &findings);
if !artifacts.is_empty() {
let _ = tx.send(format!("artifacts saved: {}", artifacts.join(", "))).await;
}
RunOutput {
candidates: findings.len(),
findings,
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),
candidates,
recon,
artifacts,
}
}
/// Write recon/exploit/findings/report as json+md for downstream reuse.
fn persist(cfg: &RunConfig, recon: &str, transcript: &str, findings: &[Finding]) -> Vec<String> {
let Some(dir) = &cfg.workdir else { return vec![] };
let dir = PathBuf::from(dir);
if std::fs::create_dir_all(&dir).is_err() {
return vec![];
}
let mut written = Vec::new();
let mut put = |name: &str, content: String| {
let p = dir.join(name);
if std::fs::write(&p, content).is_ok() {
written.push(p.display().to_string());
}
};
put("recon.json", recon.to_string());
put("recon.md", format!("# Recon — {}\n\n```json\n{}\n```\n", cfg.target, recon));
if !transcript.is_empty() {
put("exploitation.md", format!("# Agent transcript — {}\n\n{}", cfg.target, transcript));
}
put("findings.json", serde_json::to_string_pretty(findings).unwrap_or_else(|_| "[]".into()));
put("findings.md", findings_md(&cfg.target, findings));
put("report.html", report::html(&cfg.target, findings));
written
}
fn findings_md(target: &str, findings: &[Finding]) -> String {
let mut s = format!("# NeuroSploit findings — {}\n\n{} validated finding(s).\n", target, findings.len());
for (i, f) in findings.iter().enumerate() {
s.push_str(&format!(
"\n## {}. [{}] {}\n- agent: `{}` CWE: {} CVSS: {} votes: {} confidence: {:.2}\n- endpoint: {}\n\n**Payload**\n```\n{}\n```\n\n**Evidence**\n{}\n\n**Impact:** {}\n\n**Remediation:** {}\n",
i + 1, f.severity, f.title, f.agent, f.cwe, f.cvss, f.votes, f.confidence, f.endpoint, f.payload, f.evidence, f.impact, f.remediation
));
}
s
}
fn transcript_of(raw: &[(String, String, Vec<Finding>)]) -> String {
raw.iter().map(|(n, t, f)| format!("## {} ({} candidate)\n\n{}\n", n, f.len(), t)).collect::<Vec<_>>().join("\n")
}
/// 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(']')) {
@@ -154,7 +335,42 @@ fn extract_findings(text: &str, agent: &str) -> Vec<Finding> {
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>());
f.id = format!("{}-{}", agent, f.title.chars().take(12).collect::<String>());
}
}
out
}
/// Concatenate source files under `root` into a bounded review context.
fn collect_repo_context(root: &Path, max_files: usize, max_bytes: usize) -> String {
const EXTS: &[&str] = &[
"rs", "py", "js", "ts", "tsx", "jsx", "go", "java", "php", "rb", "c", "cc", "cpp", "h", "hpp",
"cs", "kt", "swift", "scala", "sh", "sql", "html", "vue", "yml", "yaml", "tf",
];
let mut out = String::new();
let mut files = 0usize;
if !root.exists() {
return out;
}
for entry in walkdir::WalkDir::new(root).max_depth(8).into_iter().flatten() {
if files >= max_files || out.len() >= max_bytes {
break;
}
let path = entry.path();
let s = path.to_string_lossy();
if s.contains("/.git/") || s.contains("/node_modules/") || s.contains("/target/") || s.contains("/vendor/") {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !EXTS.contains(&ext) {
continue;
}
if let Ok(content) = std::fs::read_to_string(path) {
let rel = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let budget = max_bytes.saturating_sub(out.len());
let take = content.len().min(budget).min(8_000);
out.push_str(&format!("\n// ===== file: {} =====\n{}\n", rel, &content[..take]));
files += 1;
}
}
out
+15 -4
View File
@@ -13,14 +13,21 @@ pub struct ModelPool {
sem: Arc<Semaphore>,
pub candidates: Vec<ModelRef>,
pub subscription: bool,
/// Path to an `.mcp.json` (Playwright) used on the subscription/CLI path.
pub mcp_config: Option<String>,
}
impl ModelPool {
pub fn new(models: Vec<ModelRef>, concurrency: usize) -> Self {
Self::with_auth(models, concurrency, false)
Self::with_auth(models, concurrency, false, None)
}
pub fn with_auth(models: Vec<ModelRef>, concurrency: usize, subscription: bool) -> Self {
pub fn with_auth(
models: Vec<ModelRef>,
concurrency: usize,
subscription: bool,
mcp_config: Option<String>,
) -> Self {
let concurrency = concurrency.max(1);
ModelPool {
client: ChatClient::new(),
@@ -31,13 +38,17 @@ impl ModelPool {
models
},
subscription,
mcp_config,
}
}
/// One completion for a model, via subscription CLI or HTTP API.
/// One completion for a model, via subscription CLI (optionally with MCP) or HTTP API.
async fn one(&self, m: &ModelRef, system: &str, user: &str) -> Result<String> {
if self.subscription && cli_binary_for(&m.provider).is_some() {
return self.client.chat_cli(&m.provider, &m.model, system, user).await;
return self
.client
.chat_cli(&m.provider, &m.model, system, user, self.mcp_config.as_deref())
.await;
}
self.client.chat(m, system, user).await
}
+60
View File
@@ -0,0 +1,60 @@
//! Lightweight reinforcement-learning reward store for the harness.
//!
//! Each agent carries a weight in [0.05, 1.0]; validated findings reward it,
//! idle runs decay it slightly. Weights bias agent ordering on future runs and
//! persist to a JSON file so the harness gets sharper over time.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Default, Serialize, Deserialize)]
pub struct RlState {
#[serde(default)]
pub weights: HashMap<String, f64>,
#[serde(default)]
pub runs: u64,
}
const ALPHA: f64 = 0.3;
const WMIN: f64 = 0.05;
const WMAX: f64 = 1.0;
impl RlState {
pub fn load(path: &Path) -> RlState {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn weight(&self, agent: &str) -> f64 {
*self.weights.get(agent).unwrap_or(&0.5)
}
/// Reward in [-1, 1]; e.g. severity-weighted hits positive, idle negative.
pub fn update(&mut self, agent: &str, reward: f64) {
let w = self.weights.entry(agent.to_string()).or_insert(0.5);
*w = (*w + ALPHA * (reward - *w)).clamp(WMIN, WMAX);
}
pub fn save(&self, path: &Path) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(s) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(path, s);
}
}
}
/// Severity → reward weight.
pub fn severity_reward(sev: &str) -> f64 {
match sev {
"Critical" => 1.0,
"High" => 0.7,
"Medium" => 0.4,
"Low" => 0.2,
_ => 0.05,
}
}
@@ -74,6 +74,12 @@ pub struct RunConfig {
/// of HTTP API keys.
#[serde(default)]
pub subscription: bool,
/// Directory to persist run artifacts (recon/exploit/findings json+md).
#[serde(default)]
pub workdir: Option<String>,
/// Path to the RL reward state file.
#[serde(default)]
pub rl_path: Option<String>,
}
fn default_vote() -> usize {
@@ -93,6 +99,8 @@ impl RunConfig {
max_agents: 0,
offline: false,
subscription: false,
workdir: None,
rl_path: None,
}
}
}