v3.5.1 fix: critical char-boundary panic (was dropping findings) + background runs, progress bar, severity colors, /help

CRITICAL BUG: truncate()/source-context slices cut strings by BYTE, panicking on
a multibyte char (e.g. '—'). The panic crashed agent tasks → task.await returned
JoinError → unwrap_or_default() → empty RunOutput. Result: real confirmed findings
(win.ini traversal, HTML injection) were silently lost, workdir was empty, report
missing. Now all string truncation is char-safe (models.rs, pipeline.rs, repl.rs).

Also:
- Background runs: /run now runs in the BACKGROUND via rustyline's ExternalPrinter
  — the REPL keeps accepting commands while the engagement streams live. New
  /status (live phase + progress bar + findings) and /stop (graceful). Findings
  persist to history + report on completion (finalize_run ensures workdir is set
  even on abort, fixing "no report file in ").
- Progress bar: agents-done/total with %, shown in /status.
- Severity colors in the live feed (Critical=red…Info=grey); confirmed vote = green.
- /help reformatted into clear aligned sections.
- TUTORIAL: document non-blocking runs, /status progress, /stop, colors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-24 23:04:50 -03:00
parent ab0161ee53
commit df73c0e134
8 changed files with 359 additions and 75 deletions
+14
View File
@@ -247,6 +247,20 @@ A context bar shows `model auth · cwd · mode▸target`. Key commands:
Line editing: **↑/↓** history, **Tab** completes commands & `@paths`, **Ctrl-A/E/K**,
end a line with **`\`** for multiline.
### Runs are non-blocking
`/run` launches the engagement **in the background** and immediately returns the
prompt — you keep typing while it streams live above the prompt. While it runs:
- **`/status`** — live phase, a **progress bar** (agents done / total), elapsed
time, token/cost and the possible findings so far.
- **`/stop`** — gracefully stop (a report is still generated from partial results).
- Findings are color-coded by severity (Critical = red … Info = grey), and a
confirmed vote shows green ✓.
- When it finishes you get `◀ run #n done — N validated finding(s) · /results n · /report n`.
(When stdin is piped/non-interactive, `/run` falls back to blocking mode.)
---
## 7. Mission Control TUI
+11
View File
@@ -11,3 +11,14 @@ run
/reports
/report
/exit
/show
/run
/results
/report
/help
/results
/target http://testasp.vulnweb.com/
/run
/results
/report
/exit
+14
View File
@@ -5,5 +5,19 @@
"target": "https://redteamleaders.com",
"workdir": "",
"findings": []
},
{
"id": 2,
"mode": "black-box",
"target": "https://redteamleaders.com",
"workdir": "",
"findings": []
},
{
"id": 3,
"mode": "black-box",
"target": "http://testasp.vulnweb.com/",
"workdir": "",
"findings": []
}
]
+1 -1
View File
@@ -6,7 +6,7 @@
"mcp": false,
"vote_n": 3,
"max_agents": 0,
"target": "https://redteamleaders.com",
"target": "http://testasp.vulnweb.com/",
"repo": null,
"auth": null,
"creds": null,
+93 -34
View File
@@ -352,10 +352,19 @@ pub(crate) async fn run_engagement(base: &Path, cfg: RunConfig, mcp: bool, white
run_mode(base, cfg, mcp, if whitebox { Mode::White } else { Mode::Black }).await
}
async fn run_mode(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> anyhow::Result<RunOutput> {
let lib = agents::load(base);
/// A spawned engagement: the running task, its live event stream, a cancel
/// handle, and the run's output dir. Lets callers drive it blocking (run_mode)
/// or in the background (the REPL), and finalize with `finalize_run`.
pub(crate) struct Spawned {
pub task: tokio::task::JoinHandle<RunOutput>,
pub rx: tokio::sync::mpsc::Receiver<String>,
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub workdir: PathBuf,
}
// Unique, sortable run id → runs/<id>/
/// Set up + start an engagement (synchronous setup; the work runs in the task).
pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> Spawned {
let lib = agents::load(base);
let run_id = format!("ns-{}-{}", now_ts(), sanitize(&cfg.target));
let workdir = base.join("runs").join(&run_id);
std::fs::create_dir_all(&workdir).ok();
@@ -376,22 +385,16 @@ async fn run_mode(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> any
if cfg.subscription { " · subscription" } else { " · api" },
if mcp { " · mcp" } else { "" });
// Playwright MCP: only for backends that support it; auto-provision if asked.
let mcp_config = if mcp && cfg.subscription {
let providers: Vec<String> = cfg.models.iter().map(|m| ModelRef::parse(m).provider).collect();
if providers.iter().any(|p| harness::mcp_supported(p)) {
match harness::ensure_playwright_mcp() {
Ok(()) => {
// Optional user-supplied extra MCP servers merged into the pipeline.
let extra = base.join("mcp.servers.json");
let extra_ref = if extra.is_file() { Some(extra.as_path()) } else { None };
match harness::write_mcp_config(&workdir, extra_ref) {
Ok(p) => {
if extra_ref.is_some() { println!(" [*] merged extra MCP servers from mcp.servers.json"); }
println!(" [*] Playwright MCP ready → {}", p.display());
Some(p.display().to_string())
}
Err(e) => { eprintln!(" [!] MCP config failed: {e}"); None }
Ok(p) => { println!(" [*] Playwright MCP ready → {}", p.display()); Some(p.display().to_string()) }
Err(e) => { eprintln!(" [!] MCP config failed: {e}"); None }
}
}
Err(e) => { eprintln!(" [!] Playwright MCP unavailable ({e}); using built-in tools"); None }
@@ -400,31 +403,39 @@ async fn run_mode(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> any
eprintln!(" [!] selected backend(s) don't support MCP; using built-in tools");
None
}
} else {
None
};
} else { None };
let refs: Vec<ModelRef> = cfg.models.iter().map(|s| ModelRef::parse(s)).collect();
let pool = ModelPool::with_auth(refs, cfg.concurrency, cfg.subscription, mcp_config);
let cancel = pool.cancel_handle();
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(256);
let printer = tokio::spawn(async move {
while let Some(line) = rx.recv().await {
render_line(&line);
}
});
// Run the engagement as a task so Ctrl-C can stop it gracefully (the AI's
// in-flight CLI/subprocesses are bounded; no new agents launch once cancelled).
let mut task = tokio::spawn(async move {
let out = match mode {
let (tx, rx) = tokio::sync::mpsc::channel::<String>(256);
let task = tokio::spawn(async move {
match mode {
Mode::White => harness::run_whitebox(cfg, &lib, &pool, tx).await,
Mode::Grey => harness::run_greybox(cfg, &lib, &pool, tx).await,
Mode::Host => harness::run_host(cfg, &lib, &pool, tx).await,
Mode::Black => harness::run(cfg, &lib, &pool, tx).await,
};
out
}
});
Spawned { task, rx, cancel, workdir }
}
/// Generate the report + final status for a finished run, ensuring the workdir
/// is always recorded (even on an aborted/partial run).
pub(crate) fn finalize_run(mut out: RunOutput, workdir: &Path) -> RunOutput {
if out.workdir.is_empty() { out.workdir = workdir.display().to_string(); }
if out.target.is_empty() {
out.target = workdir.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string();
}
let _ = harness::report::typst_report(&out.target, &out.findings, workdir);
write_status(workdir, "complete", &format!("\"findings\":{},\"agents_ran\":{}", out.findings.len(), out.agents_ran.len()));
out
}
async fn run_mode(base: &Path, cfg: RunConfig, mcp: bool, mode: Mode) -> anyhow::Result<RunOutput> {
let Spawned { mut task, mut rx, cancel, workdir } = spawn_engagement(base, cfg, mcp, mode);
let printer = tokio::spawn(async move {
while let Some(line) = rx.recv().await { render_line(&line); }
});
let mut cancelled = false;
@@ -453,12 +464,7 @@ async fn run_mode(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> any
}
}
// Final report via Typst (PDF if the `typst` binary is present) + HTML/MD already written.
match harness::report::typst_report(&out.target, &out.findings, &workdir) {
Ok(p) => println!(" [*] report → {}", p.display()),
Err(e) => eprintln!(" [!] typst report skipped: {e}"),
}
write_status(&workdir, "complete", &format!("\"findings\":{},\"agents_ran\":{}", out.findings.len(), out.agents_ran.len()));
let out = finalize_run(out, &workdir);
println!(" ✓ COMPLETE — {} validated finding(s) · status: {}/status.json", out.findings.len(), workdir.display());
Ok(out)
}
@@ -544,6 +550,59 @@ fn render_line(raw: &str) {
}
}
/// One-line styled rendering of a stream event — used by the background REPL run
/// (via rustyline's external printer) where multi-line cards would fight the
/// prompt. Returns None for events that shouldn't clutter the background feed.
pub(crate) fn render_compact(raw: &str) -> Option<String> {
let mut line = raw.trim_end();
let mut who = String::new();
if let Some(stripped) = line.strip_prefix('@') {
if let Some((label, rest)) = stripped.split_once(' ') { who = format!("[{label}] "); line = rest; }
}
let (tag, rest) = line.split_once(": ").unwrap_or(("", line));
let s = match tag {
"exec" | "danger" => format!("\x1b[33m ⌘ {who}{}\x1b[0m", trunc1(rest, 110)),
"net" => format!("\x1b[36m 🌐 {who}{}\x1b[0m", trunc1(rest, 110)),
"read" => format!("\x1b[34m 📄 {who}{}\x1b[0m", rest),
"tokens" => { track_tokens(rest); return None; } // counted, shown in /status
// Candidate finding — color by severity (not all-yellow).
"finding" => {
let sev = rest.strip_prefix('[').and_then(|b| b.split_once(']')).map(|(s, _)| s).unwrap_or("");
format!(" {}{who}{}\x1b[0m", sev_color(sev), rest)
}
"notify" => format!("\x1b[1;36m 🔔 {}\x1b[0m", rest),
"ai" => return None, // skip verbose model chatter in background feed
_ => {
let low = line.to_lowercase();
if low.contains("recon complete") { "\x1b[36m 🔍 recon complete\x1b[0m".into() }
else if low.contains("selected") && low.contains("agent") { format!("\x1b[36m 🧭 {}\x1b[0m", trunc1(line, 110)) }
else if low.starts_with("vote") && low.contains("confirmed") { format!("\x1b[1;32m ✓ {}\x1b[0m", trunc1(line, 110)) }
else if low.starts_with("exploit") || low.starts_with("test ") || low.contains("launching agent") { format!("\x1b[35m 🧪 {}\x1b[0m", trunc1(line, 110)) }
else if low.starts_with("vote") { format!("\x1b[2m · {}\x1b[0m", trunc1(line, 110)) }
else if low.contains("fail") || low.contains("error") { format!("\x1b[31m ✗ {}\x1b[0m", trunc1(line, 110)) }
else { return None; }
}
};
Some(s)
}
/// ANSI color per severity — so confirmed/critical findings stand out instead of
/// everything being yellow.
fn sev_color(sev: &str) -> &'static str {
match sev.trim() {
"Critical" => "\x1b[1;31m", // bold red
"High" => "\x1b[38;5;208m", // orange
"Medium" => "\x1b[33m", // yellow
"Low" => "\x1b[36m", // cyan
_ => "\x1b[37m", // info/grey
}
}
fn trunc1(s: &str, n: usize) -> String {
let one = s.replace('\n', " ");
if one.chars().count() <= n { one } else { format!("{}", one.chars().take(n).collect::<String>()) }
}
// Running token/cost total across the engagement (shown in the summary).
static TOK_IN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static TOK_OUT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+216 -37
View File
@@ -13,16 +13,75 @@ use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::history::FileHistory;
use rustyline::validate::{ValidationContext, ValidationResult, Validator};
use rustyline::{CompletionType, Config, Context, Editor, Helper};
use rustyline::{CompletionType, Config, Context, Editor, ExternalPrinter, Helper};
use serde::{Deserialize, Serialize};
use std::io::IsTerminal;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
/// Live state of a background run, updated from the engagement stream so the
/// composer can answer /status while the runner works.
struct RunLive {
target: String,
mode: &'static str,
phase: String,
started: Instant,
findings: Vec<(String, String)>, // sev, title
agents: usize,
agents_done: usize,
}
impl RunLive {
/// progress fraction in [0,1] (agents completed / total selected).
fn progress(&self) -> f64 {
if self.agents == 0 { return 0.0; }
(self.agents_done as f64 / self.agents as f64).clamp(0.0, 1.0)
}
fn bar(&self, width: usize) -> String {
let filled = (self.progress() * width as f64).round() as usize;
format!("[{}{}] {}/{} ({:.0}%)",
"".repeat(filled), "".repeat(width.saturating_sub(filled)),
self.agents_done, self.agents, self.progress() * 100.0)
}
fn ingest(&mut self, line: &str) {
let low = line.to_lowercase();
if low.contains("recon complete") { self.phase = "recon".into(); }
else if low.contains("selected") && low.contains("agent") {
self.phase = "planning".into();
if let Some(n) = line.split_whitespace().find_map(|t| t.parse::<usize>().ok()) { self.agents = n; }
}
else if low.starts_with("exploit") || low.starts_with("test ") || low.contains("launching agent") { self.phase = "exploiting".into(); }
else if low.starts_with("vote") || low.contains("validating") { self.phase = "validating".into(); }
else if low.starts_with("chain") { self.phase = "chaining".into(); }
else if low.contains("phase complete") || low.contains("validated finding(s)") { self.phase = "complete".into(); }
// count completed agents (each emits "... via <model> → N candidate(s)")
if low.contains("candidate(s)") && (low.starts_with("exploit ") || low.starts_with("test ") || low.starts_with("analyze ") || low.starts_with("review ")) {
self.agents_done += 1;
}
if let Some(rest) = line.strip_prefix("finding: ") {
if let Some(b) = rest.strip_prefix('[') {
if let Some((sev, tail)) = b.split_once(']') {
let title = tail.trim().split(" @ ").next().unwrap_or(tail.trim());
self.findings.push((sev.to_string(), title.to_string()));
}
}
}
}
}
/// A run executing in the background of the REPL.
struct ActiveRun {
live: Arc<Mutex<RunLive>>,
cancel: Arc<AtomicBool>,
done: Arc<AtomicBool>,
}
/// All slash-commands, for Tab completion.
const COMMANDS: &[&str] = &[
"/help", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target",
"/repo", "/auth", "/creds", "/focus", "/attach", "/context", "/mcp", "/offline",
"/votes", "/agents", "/theme", "/clear", "/run", "/runs", "/results", "/report",
"/votes", "/agents", "/theme", "/clear", "/run", "/stop", "/runs", "/results", "/report",
"/status", "/diff", "/retest", "/quit",
];
@@ -154,6 +213,15 @@ impl Reader {
Reader::Plain(std::io::stdin())
}
/// An external printer that can write *above* the prompt from another task —
/// this is what lets a background run stream live while you keep typing.
fn external_printer(&mut self) -> Option<Box<dyn ExternalPrinter + Send>> {
match self {
Reader::Rl(ed, _) => ed.create_external_printer().ok().map(|p| Box::new(p) as Box<dyn ExternalPrinter + Send>),
Reader::Plain(_) => None,
}
}
/// Returns None to exit (EOF / Ctrl-D), Some(line) otherwise. Ctrl-C cancels
/// the current line (returns an empty string) instead of exiting.
/// `prompt` is the dynamic context bar + prompt to show.
@@ -202,12 +270,14 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
let mut s = Session::default();
let resumed = load_session(&mut s);
let mut history: Vec<RunRecord> = load_runs(base);
if resumed || !history.is_empty() {
println!(" ↻ resumed project session from {}{} past run(s)\n",
proj_dir().display(), history.len());
// Shared so a background run's forwarder task can append to it.
let history: Arc<Mutex<Vec<RunRecord>>> = Arc::new(Mutex::new(load_runs(base)));
let past = history.lock().unwrap().len();
if resumed || past > 0 {
println!(" ↻ resumed project session from {}{} past run(s)\n", proj_dir().display(), past);
}
let mut reader = Reader::new(base);
let mut active: Option<ActiveRun> = None;
show(&s);
loop {
@@ -291,16 +361,30 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
"/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" => {
save_session(&s);
println!("\n\x1b[1;35m▶ RUNNING engagement\x1b[0m \x1b[2m— output streams below; the REPL prompt returns when it finishes. (For a live interactive run, use: neurosploit tui <url>)\x1b[0m\n");
run(base, &s, &mut history).await;
save_runs(base, &history);
println!("\n\x1b[1;32m◀ back to the NeuroSploit REPL\x1b[0m \x1b[2m— /results /report /runs /diff /show\x1b[0m");
if active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false) {
println!(" a run is already active — /status to check, /stop to halt it.");
} else {
save_session(&s);
match start_background(base, &s, &mut reader, history.clone()).await {
Some(a) => { active = Some(a); println!(" \x1b[1;35m▶ running in background\x1b[0m — keep typing · \x1b[36m/status\x1b[0m · \x1b[36m/stop\x1b[0m"); }
None => { // no external printer (piped) → blocking fallback
let mut h = history.lock().unwrap();
run(base, &s, &mut h).await; save_runs(base, &h);
}
}
}
}
"/runs" | "/history" => list_runs(&history),
"/diff" | "/changed" => diff_runs(&history),
"/stop" => {
match &active {
Some(a) if !a.done.load(Ordering::Relaxed) => { a.cancel.store(true, Ordering::Relaxed); println!(" ⏸ stopping — finishing in-flight work; a report is generated on completion."); }
_ => println!(" no active run."),
}
}
"/runs" | "/history" => list_runs(&history.lock().unwrap()),
"/diff" | "/changed" => diff_runs(&history.lock().unwrap()),
"/retest" => {
if let Some(r) = pick(&history, arg) {
let h = history.lock().unwrap();
if let Some(r) = pick(&h, arg) {
if r.target.starts_with('/') { s.repo = Some(r.target.clone()); s.target = None; }
else { s.target = Some(r.target.clone()); }
let titles: Vec<String> = r.findings.iter().map(|f| f.title.clone()).collect();
@@ -310,10 +394,32 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
println!(" ↻ retest set up for {} ({} prior finding(s)) — /run to launch", r.target, titles.len());
}
}
"/results" => results(&history, arg),
"/report" => open_report(&history, arg),
"/status" => run_status(&history, arg),
"/quit" | "/exit" | "/q" => { save_session(&s); println!(" session saved → {} · bye.", proj_dir().display()); break; }
"/results" => results(&history.lock().unwrap(), arg),
"/report" => open_report(&history.lock().unwrap(), arg),
"/status" => {
// Live status if a run is active, else a past run's status.json.
match &active {
Some(a) if arg.is_empty() && !a.done.load(Ordering::Relaxed) => {
let l = a.live.lock().unwrap();
let el = l.started.elapsed().as_secs();
let mut by: std::collections::BTreeMap<&str, usize> = Default::default();
for (sv, _) in &l.findings { *by.entry(sv.as_str()).or_insert(0) += 1; }
let sev = if by.is_empty() { "0".into() } else { by.iter().map(|(k, v)| format!("{k}:{v}")).collect::<Vec<_>>().join(" ") };
println!(" \x1b[1m▶ live\x1b[0m {} ({}) · phase {} · {:02}:{:02} · {} possible finding(s) [{}]",
l.target, l.mode, l.phase, el / 60, el % 60, l.findings.len(), sev);
if l.agents > 0 { println!(" progress \x1b[36m{}\x1b[0m", l.bar(24)); }
for (sv, t) in l.findings.iter().rev().take(5) { println!(" ✦ [{sv}] {t}"); }
}
_ => run_status(&history.lock().unwrap(), arg),
}
}
"/quit" | "/exit" | "/q" => {
if active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false) {
if let Some(a) = &active { a.cancel.store(true, Ordering::Relaxed); }
println!(" ⏸ a run is active — requested stop; quitting.");
}
save_session(&s); println!(" session saved → {} · bye.", proj_dir().display()); break;
}
other => println!(" unknown command '{other}' — try /help"),
}
}
@@ -450,6 +556,63 @@ async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
}
}
/// Launch an engagement in the BACKGROUND: it streams live via the editor's
/// external printer while the REPL keeps accepting commands (/status, /stop).
/// Returns None when no external printer is available (piped) → caller blocks.
async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
history: Arc<Mutex<Vec<RunRecord>>>) -> Option<ActiveRun> {
let (target, mode_s, mode_e, mcp) = match (&s.repo, &s.target) {
(Some(_), Some(t)) => (t.clone(), "greybox", crate::Mode::Grey, s.mcp),
(Some(r), None) => (r.clone(), "white-box", crate::Mode::White, false),
(None, Some(t)) => (t.clone(), "black-box", crate::Mode::Black, s.mcp),
_ => { println!(" \x1b[31m✗ set a /target <url> and/or /repo <path> first.\x1b[0m"); return None; }
};
let mut cfg = RunConfig::new(&target);
cfg.models = s.models.clone();
cfg.subscription = s.subscription;
cfg.vote_n = s.vote_n;
cfg.max_agents = s.max_agents;
cfg.verbose = true;
cfg.offline = s.offline;
cfg.instructions = if s.attachments.is_empty() { s.instructions.clone() }
else { Some(format!("{}\n\nATTACHED CONTEXT:\n{}", s.instructions.clone().unwrap_or_default(), s.attachments.join("\n\n"))) };
cfg.auth = s.auth.clone();
if matches!(mode_e, crate::Mode::Grey) { cfg.repo = s.repo.clone(); }
crate::apply_creds(&mut cfg, s.creds.as_deref()).await;
let mut printer = reader.external_printer()?; // None on piped stdin → blocking fallback
let sp = crate::spawn_engagement(base, cfg, mcp, mode_e);
let live = Arc::new(Mutex::new(RunLive {
target: target.clone(), mode: mode_s, phase: "starting".into(),
started: Instant::now(), findings: vec![], agents: 0, agents_done: 0,
}));
let cancel = sp.cancel.clone();
let done = Arc::new(AtomicBool::new(false));
let (live2, done2, hist2) = (live.clone(), done.clone(), history);
tokio::spawn(async move {
let crate::Spawned { task, mut rx, workdir, .. } = sp;
while let Some(line) = rx.recv().await {
live2.lock().unwrap().ingest(&line);
if let Some(out) = crate::render_compact(&line) { let _ = printer.print(out); }
}
let out = crate::finalize_run(task.await.unwrap_or_default(), &workdir);
let id = {
let mut h = hist2.lock().unwrap();
let id = h.len() + 1;
h.push(RunRecord { id, mode: mode_s.into(), target, workdir: out.workdir.clone(), findings: out.findings.clone() });
if let Ok(j) = serde_json::to_string_pretty(&*h) { std::fs::write(proj_dir().join("runs.json"), j).ok(); }
id
};
let _ = printer.print(format!(
"\x1b[1;32m◀ run #{id} done — {} validated finding(s)\x1b[0m · /results {id} · /report {id}",
out.findings.len()));
done2.store(true, Ordering::Relaxed);
});
Some(ActiveRun { live, cancel, done })
}
/// Project-local store: `<cwd>/.neurosploit/` so each project keeps its own
/// session, run history and command history (resume on reopen). No DB needed —
/// it's structured state, not semantic search.
@@ -616,24 +779,37 @@ fn show(s: &Session) {
}
fn help() {
println!(" Commands (↑/↓ recall history · Ctrl-A/E/K edit · Ctrl-C cancels line):");
println!(" /model [a:b,..] set models; with no arg → arrow-key multi-select");
println!(" /providers list providers & models");
println!(" /key [prov key] configure API keys for your models (no arg → guided)");
println!(" /sub on|off use local subscription login instead of API key");
println!(" /target <url> black-box target URL");
println!(" /repo <path> analyse a repo (repo+target = greybox: code + live)");
println!(" /auth <value> auth header (e.g. 'Authorization: Bearer <jwt>')");
println!(" /creds <file.yaml> credentials (jwt/header/cookie/login) for authed tests");
println!(" /focus <text> steer the tests (or just type it); e.g. injection + access control");
println!(" @path @dir @f:1-20 attach a file/folder/line-range to context (Tab-completes)");
println!(" /attach <path> attach context · /context list attachments");
println!(" /mcp on|off Playwright MCP browser /offline on|off self-test");
println!(" /theme color|mono /config (=/show) /votes <n> /agents <n>");
println!(" Tab completes commands & @paths · ↑/↓ history · end a line with \\ for multiline");
println!(" /run launch · /runs /results [n] /report [n] /status [n]");
println!(" /diff what changed vs the previous run · /retest [n] re-verify a past run");
println!(" /quit exit");
let h = |c: &str, d: &str| println!(" \x1b[36m{c:<20}\x1b[0m {d}");
println!("\n \x1b[1mNeuroSploit REPL — commands\x1b[0m");
println!("\n \x1b[2mTARGET & SCOPE\x1b[0m");
h("/target <url>", "black-box target URL");
h("/repo <path>", "analyse a repo (repo + target = greybox: code + live)");
h("/auth <value>", "auth header, e.g. 'Authorization: Bearer <jwt>' (no arg = show)");
h("/creds <file.yaml>", "credentials: jwt/header/cookie/login + ssh/windows");
h("/focus <text>", "steer the tests (or just type the instruction)");
h("@path @dir @f:1-20", "attach a file/folder/line-range to context (Tab → menu)");
h("/attach /context", "attach a path · list attachments");
println!("\n \x1b[2mMODELS & AUTH\x1b[0m");
h("/model [a:b,..]", "set models (no arg → arrow-key multi-select)");
h("/providers", "list providers & models");
h("/key [prov key]", "configure API keys for your models (no arg → guided)");
h("/sub on|off", "use local subscription login instead of an API key");
println!("\n \x1b[2mRUN & MONITOR\x1b[0m");
h("/run", "launch (runs in the BACKGROUND — keep typing)");
h("/status", "live progress + findings while running (or a past run #)");
h("/stop", "gracefully stop the active run");
h("/runs", "list runs · /results [n] · /report [n]");
h("/diff /retest [n]", "what changed vs last run · re-verify a past run");
println!("\n \x1b[2mOPTIONS\x1b[0m");
h("/mcp on|off", "Playwright MCP browser /offline on|off self-test");
h("/votes <n>", "validator votes /agents <n> cap agents");
h("/theme color|mono", "/show (config) /clear /quit");
println!("\n \x1b[2m↑/↓ history · Tab completes commands & @paths · Ctrl-A/E/K edit · \\ for multiline\x1b[0m\n");
}
/// Scan a line for @path tokens, attach each referenced file/dir to context.
@@ -709,4 +885,7 @@ fn context_prompt(s: &Session) -> String {
}
fn onoff(b: bool) -> &'static str { if b { "on" } else { "off" } }
fn trunc(s: &str, n: usize) -> String { if s.len() <= n { s.to_string() } else { format!("{}", &s[..n.saturating_sub(1)]) } }
fn trunc(s: &str, n: usize) -> String {
if s.chars().count() <= n { s.to_string() }
else { format!("{}", s.chars().take(n.saturating_sub(1)).collect::<String>()) }
}
+5 -2
View File
@@ -416,9 +416,12 @@ impl Default for ChatClient {
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
// Truncate by CHARACTERS, never bytes — slicing `&s[..n]` panics when `n`
// lands inside a multi-byte char (e.g. '—'). That panic was crashing agent
// tasks and silently dropping their findings.
if s.chars().count() <= n {
s.to_string()
} else {
format!("{}", &s[..n])
format!("{}", s.chars().take(n).collect::<String>())
}
}
@@ -862,7 +862,11 @@ fn collect_repo_context(root: &Path, max_files: usize, max_bytes: usize) -> Stri
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]));
// Char-safe slice: back off to the nearest char boundary so multibyte
// source files (UTF-8) never panic.
let mut end = take.min(content.len());
while end > 0 && !content.is_char_boundary(end) { end -= 1; }
out.push_str(&format!("\n// ===== file: {} =====\n{}\n", rel, &content[..end]));
files += 1;
}
}