v3.5.0: complete REPL — run history, /results, /report, /status, /offline

- RunOutput exposes `workdir` so the session can locate reports.
- Session now records every run (RunRecord: id, mode, target, workdir, findings).
- New commands:
    /runs            list runs done this session (mode, target, severity counts)
    /results [n]     show findings of run n (default last), severity-sorted
    /report [n]      open the PDF/HTML report (open/xdg-open)
    /status [n]      print the run's status.json
    /offline on|off  pipeline self-test toggle (no model calls)
- Each /run prints "saved as run #n" with the quick commands.
- Verified offline: run → /runs → /results → /status all work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-24 20:21:35 -03:00
parent ae3e49f133
commit f21b96e8c1
2 changed files with 133 additions and 7 deletions
+127 -4
View File
@@ -6,10 +6,19 @@
//! steer which agents run and how — e.g. "find injection and broken access
//! control". `/run` then executes the engagement with that configuration.
use harness::{agents, types::RunConfig};
use harness::{agents, types::Finding, types::RunConfig};
use std::io::Write;
use std::path::Path;
/// A run completed within this interactive session (for /runs, /results, /report).
struct RunRecord {
id: usize,
mode: String,
target: String,
workdir: String,
findings: Vec<Finding>,
}
/// Mutable session state edited via slash-commands and consumed by `/run`.
struct Session {
models: Vec<String>,
@@ -17,6 +26,7 @@ struct Session {
mcp: bool,
vote_n: usize,
max_agents: usize,
offline: bool,
target: Option<String>,
repo: Option<String>,
auth: Option<String>,
@@ -32,6 +42,7 @@ impl Default for Session {
mcp: false,
vote_n: 3,
max_agents: 0,
offline: false,
target: None,
repo: None,
auth: None,
@@ -58,6 +69,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
println!(" Type \x1b[36m/help\x1b[0m to get started, \x1b[36m/run\x1b[0m to launch, \x1b[36m/quit\x1b[0m to exit.\n");
let mut s = Session::default();
let mut history: Vec<RunRecord> = Vec::new();
show(&s);
let stdin = std::io::stdin();
@@ -146,10 +158,18 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
s.mcp = !matches!(arg, "off" | "false" | "0" | "no");
println!(" Playwright MCP: {}", onoff(s.mcp));
}
"/offline" => {
s.offline = !matches!(arg, "off" | "false" | "0" | "no");
println!(" offline (pipeline self-test): {}", onoff(s.offline));
}
"/votes" => { s.vote_n = arg.parse().unwrap_or(s.vote_n); println!(" votes: {}", s.vote_n); }
"/agents" => { s.max_agents = arg.parse().unwrap_or(s.max_agents); println!(" max agents: {} ", s.max_agents); }
"/clear" => { print!("\x1b[2J\x1b[H"); }
"/run" | "/go" => run(base, &s).await,
"/run" | "/go" => run(base, &s, &mut history).await,
"/runs" | "/history" => list_runs(&history),
"/results" => results(&history, arg),
"/report" => open_report(&history, arg),
"/status" => run_status(&history, arg),
"/quit" | "/exit" | "/q" => { println!(" bye."); break; }
other => println!(" unknown command '{other}' — try /help"),
}
@@ -157,7 +177,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
Ok(())
}
async fn run(base: &Path, s: &Session) {
async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
// repo + target → greybox; repo only → whitebox; target only → black-box.
enum M { Black(String), White(String), Grey { url: String, repo: String } }
let m = match (&s.repo, &s.target) {
@@ -179,6 +199,7 @@ async fn run(base: &Path, s: &Session) {
cfg.vote_n = s.vote_n;
cfg.max_agents = s.max_agents;
cfg.verbose = true;
cfg.offline = s.offline;
cfg.instructions = s.instructions.clone();
cfg.auth = s.auth.clone();
if let M::Grey { repo, .. } = &m {
@@ -186,17 +207,114 @@ async fn run(base: &Path, s: &Session) {
}
crate::apply_creds(&mut cfg, s.creds.as_deref()).await;
let mode = match &m {
M::Grey { .. } => "greybox",
M::White(_) => "white-box",
M::Black(_) => "black-box",
};
let result = match m {
M::Grey { .. } => crate::run_greybox_engagement(base, cfg, s.mcp).await,
M::White(_) => crate::run_engagement(base, cfg, false, true).await,
M::Black(_) => crate::run_engagement(base, cfg, s.mcp, false).await,
};
match result {
Ok(out) => crate::print_findings(&out),
Ok(out) => {
crate::print_findings(&out);
let id = history.len() + 1;
println!(" ↳ saved as run #{id} — /results {id} · /report {id} · /status {id}");
history.push(RunRecord {
id, mode: mode.into(), target: primary,
workdir: out.workdir.clone(), findings: out.findings.clone(),
});
}
Err(e) => println!(" \x1b[31m✗ run failed: {e}\x1b[0m"),
}
}
/// Resolve a run by 1-based index argument; default = most recent.
fn pick<'a>(history: &'a [RunRecord], arg: &str) -> Option<&'a RunRecord> {
if history.is_empty() {
println!(" no runs yet this session — /run first.");
return None;
}
if arg.trim().is_empty() {
return history.last();
}
match arg.trim().parse::<usize>() {
Ok(n) => history.iter().find(|r| r.id == n).or_else(|| {
println!(" no run #{n} (have 1..{})", history.len()); None
}),
Err(_) => { println!(" usage: with a run number, e.g. /results 2"); None }
}
}
fn sev_counts(f: &[Finding]) -> std::collections::BTreeMap<&str, usize> {
let mut m = std::collections::BTreeMap::new();
for x in f { *m.entry(x.severity.as_str()).or_insert(0) += 1; }
m
}
fn list_runs(history: &[RunRecord]) {
if history.is_empty() { println!(" no runs yet this session."); return; }
println!(" ┌─ session runs");
for r in history {
let c = sev_counts(&r.findings);
let sev = if c.is_empty() { "0 findings".to_string() }
else { c.iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().join(" ") };
println!(" │ #{:<2} {:<9} {:<40} {}", r.id, r.mode, trunc(&r.target, 40), sev);
}
println!(" └─ /results <n> · /report <n> · /status <n>");
}
fn results(history: &[RunRecord], arg: &str) {
let Some(r) = pick(history, arg) else { return };
println!(" ── run #{} ({}) — {} ──", r.id, r.mode, r.target);
if r.findings.is_empty() {
println!(" (no validated findings)");
return;
}
let mut f = r.findings.clone();
f.sort_by_key(|x| match x.severity.as_str() {
"Critical" => 0, "High" => 1, "Medium" => 2, "Low" => 3, _ => 4,
});
for x in &f {
println!(" • [{}] {}", x.severity, x.title);
println!(" {} · {} · votes {} · conf {:.2}", x.agent, x.cwe, x.votes, x.confidence);
if !x.endpoint.is_empty() { println!(" @ {}", x.endpoint); }
}
println!(" report: /report {}", r.id);
}
fn open_report(history: &[RunRecord], arg: &str) {
let Some(r) = pick(history, arg) else { return };
let dir = Path::new(&r.workdir);
let pdf = dir.join("report.pdf");
let html = dir.join("report.html");
let file = if pdf.is_file() { pdf } else { html };
if !file.is_file() {
println!(" no report file in {}", r.workdir);
return;
}
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
match std::process::Command::new(opener).arg(&file).spawn() {
Ok(_) => println!(" opening {}", file.display()),
Err(_) => println!(" report: {}", file.display()),
}
}
fn run_status(history: &[RunRecord], arg: &str) {
let Some(r) = pick(history, arg) else { return };
let sp = Path::new(&r.workdir).join("status.json");
match std::fs::read_to_string(&sp) {
Ok(txt) => println!(" run #{}: {}", r.id, txt.trim()),
Err(_) => println!(" run #{}: no status.json ({})", r.id, r.workdir),
}
}
fn trunc(s: &str, n: usize) -> String {
if s.len() <= n { s.to_string() } else { format!("{}", &s[..n.saturating_sub(1)]) }
}
fn show(s: &Session) {
println!(" ┌─ session");
println!(" │ models : {}", s.models.join(", "));
@@ -230,10 +348,15 @@ fn help() {
println!(" /focus <text> steer the tests, e.g. 'injection and broken access control'");
println!(" (or just type the instruction with no slash)");
println!(" /mcp on|off enable Playwright MCP browser (subscription path)");
println!(" /offline on|off pipeline self-test (no model calls)");
println!(" /votes <n> validator votes per finding");
println!(" /agents <n> cap agents (0 = all matching)");
println!(" /show show current session config");
println!(" /run launch the engagement");
println!(" /runs list runs done this session (history)");
println!(" /results [n] show findings of run n (default: last)");
println!(" /report [n] open the PDF/HTML report of run n");
println!(" /status [n] show status.json of run n");
println!(" /quit exit");
println!();
println!(" Example:");
@@ -16,6 +16,8 @@ pub struct RunOutput {
pub agents_ran: Vec<String>,
pub candidates: usize,
pub recon: String,
/// The run's output directory (runs/ns-<ts>-<target>/).
pub workdir: String,
/// Paths to persisted artifacts (recon/exploit/findings/report), if any.
pub artifacts: Vec<String>,
}
@@ -112,7 +114,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
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 { target: cfg.target.clone(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
}
// Use the model to pick the agents whose preconditions match the recon —
@@ -218,7 +220,7 @@ pub async fn run_whitebox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: S
if cfg.offline || bytes == 0 {
let artifacts = persist(&cfg, "{}", &context, &[]);
return RunOutput { target: cfg.target.clone(), findings: vec![], agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon: String::new(), artifacts };
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), 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())
@@ -326,7 +328,7 @@ pub async fn run_greybox(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Se
let selected: Vec<Agent> = ranked.into_iter().take(cap).collect();
let _ = tx.send(format!("offline: selected {} agent(s); no live exploitation", selected.len())).await;
let artifacts = persist(&cfg, &recon, &code_leads, &[]);
return RunOutput { target: cfg.target.clone(), findings: vec![],
return RunOutput { target: cfg.target.clone(), workdir: cfg.workdir.clone().unwrap_or_default(), findings: vec![],
agents_ran: selected.iter().map(|a| a.name.clone()).collect(), candidates: 0, recon, artifacts };
}
@@ -570,6 +572,7 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
RunOutput {
target: cfg.target.clone(),
workdir: cfg.workdir.clone().unwrap_or_default(),
candidates: findings.len(),
findings,
agents_ran: selected.iter().map(|a| a.name.clone()).collect(),