From df73c0e134582c85100c1084558f454eb59c3748 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Wed, 24 Jun 2026 23:04:50 -0300 Subject: [PATCH] v3.5.1 fix: critical char-boundary panic (was dropping findings) + background runs, progress bar, severity colors, /help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- TUTORIAL.md | 14 + neurosploit-rs/.neurosploit/history.txt | 11 + neurosploit-rs/.neurosploit/runs.json | 14 + neurosploit-rs/.neurosploit/session.json | 2 +- neurosploit-rs/app/src/main.rs | 127 ++++++--- neurosploit-rs/app/src/repl.rs | 253 +++++++++++++++--- neurosploit-rs/crates/harness/src/models.rs | 7 +- neurosploit-rs/crates/harness/src/pipeline.rs | 6 +- 8 files changed, 359 insertions(+), 75 deletions(-) diff --git a/TUTORIAL.md b/TUTORIAL.md index 94b1329..4c663d8 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -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 diff --git a/neurosploit-rs/.neurosploit/history.txt b/neurosploit-rs/.neurosploit/history.txt index 7abfd97..c88fc3d 100644 --- a/neurosploit-rs/.neurosploit/history.txt +++ b/neurosploit-rs/.neurosploit/history.txt @@ -11,3 +11,14 @@ run /reports /report /exit +/show +/run +/results +/report +/help +/results +/target http://testasp.vulnweb.com/ +/run +/results +/report +/exit diff --git a/neurosploit-rs/.neurosploit/runs.json b/neurosploit-rs/.neurosploit/runs.json index 2fa1eda..a1d546d 100644 --- a/neurosploit-rs/.neurosploit/runs.json +++ b/neurosploit-rs/.neurosploit/runs.json @@ -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": [] } ] \ No newline at end of file diff --git a/neurosploit-rs/.neurosploit/session.json b/neurosploit-rs/.neurosploit/session.json index 5404d13..6669c1c 100644 --- a/neurosploit-rs/.neurosploit/session.json +++ b/neurosploit-rs/.neurosploit/session.json @@ -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, diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index eaafafd..c5f6bea 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -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 { - 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, + pub rx: tokio::sync::mpsc::Receiver, + pub cancel: std::sync::Arc, + pub workdir: PathBuf, +} - // Unique, sortable run id → runs// +/// 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 = 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 = 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::(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::(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 { + 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 { + 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::()) } +} + // 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); diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index ff814b1..37e9bda 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -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::().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 → 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>, + cancel: Arc, + done: Arc, +} /// 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> { + match self { + Reader::Rl(ed, _) => ed.create_external_printer().ok().map(|p| Box::new(p) as Box), + 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 = 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>> = 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 = 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 )\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 = 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::>().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) { } } +/// 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>>) -> Option { + 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 and/or /repo 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: `/.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 black-box target URL"); - println!(" /repo analyse a repo (repo+target = greybox: code + live)"); - println!(" /auth auth header (e.g. 'Authorization: Bearer ')"); - println!(" /creds credentials (jwt/header/cookie/login) for authed tests"); - println!(" /focus 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 attach context · /context list attachments"); - println!(" /mcp on|off Playwright MCP browser /offline on|off self-test"); - println!(" /theme color|mono /config (=/show) /votes /agents "); - 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 ", "black-box target URL"); + h("/repo ", "analyse a repo (repo + target = greybox: code + live)"); + h("/auth ", "auth header, e.g. 'Authorization: Bearer ' (no arg = show)"); + h("/creds ", "credentials: jwt/header/cookie/login + ssh/windows"); + h("/focus ", "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 ", "validator votes /agents 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::()) } +} diff --git a/neurosploit-rs/crates/harness/src/models.rs b/neurosploit-rs/crates/harness/src/models.rs index 11caa9a..b0cde10 100644 --- a/neurosploit-rs/crates/harness/src/models.rs +++ b/neurosploit-rs/crates/harness/src/models.rs @@ -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::()) } } diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 0c65d99..3b59440 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -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; } }