diff --git a/.gitignore b/.gitignore index b440439..ab02212 100644 --- a/.gitignore +++ b/.gitignore @@ -100,3 +100,5 @@ runs/ data/rl_state_rs.json neurosploit-rs/runs/ v34_gui.png +data/repl_runs.json +data/repl_history.txt diff --git a/neurosploit-rs/Cargo.lock b/neurosploit-rs/Cargo.lock index c5b1bbf..696f870 100644 --- a/neurosploit-rs/Cargo.lock +++ b/neurosploit-rs/Cargo.lock @@ -113,6 +113,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -159,12 +165,47 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width 0.2.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -176,6 +217,18 @@ dependencies = [ "syn", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "errno" version = "0.3.14" @@ -186,6 +239,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -322,6 +398,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -558,6 +643,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -608,8 +699,10 @@ version = "3.5.0" dependencies = [ "anyhow", "clap", + "dialoguer", "futures", "neurosploit-harness", + "rustyline", "serde", "serde_json", "tokio", @@ -629,6 +722,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -710,14 +824,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -738,7 +852,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -750,7 +864,7 @@ version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -773,6 +887,16 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.9.4" @@ -898,6 +1022,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -939,6 +1076,28 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.1.14", + "utf8parse", + "windows-sys 0.52.0", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1015,6 +1174,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -1102,13 +1267,46 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1261,6 +1459,24 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -1433,6 +1649,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/neurosploit-rs/app/Cargo.toml b/neurosploit-rs/app/Cargo.toml index 1b0d85e..84e26fa 100644 --- a/neurosploit-rs/app/Cargo.toml +++ b/neurosploit-rs/app/Cargo.toml @@ -16,3 +16,5 @@ tokio.workspace = true anyhow.workspace = true futures.workspace = true clap = { version = "4", features = ["derive"] } +rustyline = "14" +dialoguer = "0.11" diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index a63217d..8b4add2 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -1,16 +1,20 @@ //! NeuroSploit v3.5.0 — interactive session (Claude-Code / Codex / Cursor-CLI style). //! -//! Launched when `neurosploit` runs with no subcommand. A persistent REPL where -//! you pick models, set an API key (or use a subscription login), point at a URL -//! or a repo, configure authentication, and write free-text instructions that -//! steer which agents run and how — e.g. "find injection and broken access -//! control". `/run` then executes the engagement with that configuration. +//! Launched when `neurosploit` runs with no subcommand. A persistent REPL with +//! real line editing (arrow-key history recall, Ctrl-A/E/K, paste), model +//! selection (arrow-key multi-select), API-key configuration based on the chosen +//! models, target/repo/auth/instructions, run history, and reports. +use dialoguer::{theme::ColorfulTheme, MultiSelect}; use harness::{agents, types::Finding, types::RunConfig}; -use std::io::Write; +use rustyline::error::ReadlineError; +use rustyline::DefaultEditor; +use serde::{Deserialize, Serialize}; +use std::io::IsTerminal; use std::path::Path; -/// A run completed within this interactive session (for /runs, /results, /report). +/// A run completed within this session (persisted to disk for /runs across sessions). +#[derive(Serialize, Deserialize, Clone)] struct RunRecord { id: usize, mode: String, @@ -19,7 +23,6 @@ struct RunRecord { findings: Vec, } -/// Mutable session state edited via slash-commands and consumed by `/run`. struct Session { models: Vec, subscription: bool, @@ -54,6 +57,54 @@ impl Default for Session { const PROMPT: &str = "\x1b[35mneurosploit›\x1b[0m "; +/// Line reader: full rustyline editing when interactive, plain stdin when piped. +enum Reader { + Rl(Box, std::path::PathBuf), + Plain(std::io::Stdin), +} + +impl Reader { + fn new(base: &Path) -> Reader { + if std::io::stdin().is_terminal() { + if let Ok(mut ed) = DefaultEditor::new() { + let hist = base.join("data").join("repl_history.txt"); + std::fs::create_dir_all(hist.parent().unwrap()).ok(); + let _ = ed.load_history(&hist); + return Reader::Rl(Box::new(ed), hist); + } + } + Reader::Plain(std::io::stdin()) + } + + /// Returns None to exit (EOF / Ctrl-D), Some(line) otherwise. Ctrl-C cancels + /// the current line (returns an empty string) instead of exiting. + fn read(&mut self) -> Option { + match self { + Reader::Rl(ed, hist) => match ed.readline(PROMPT) { + Ok(l) => { + if !l.trim().is_empty() { + let _ = ed.add_history_entry(l.as_str()); + let _ = ed.save_history(hist); + } + Some(l) + } + Err(ReadlineError::Interrupted) => Some(String::new()), // Ctrl-C: cancel line + Err(_) => None, // Ctrl-D / error: exit + }, + Reader::Plain(stdin) => { + use std::io::Write; + print!("{PROMPT}"); + std::io::stdout().flush().ok(); + let mut s = String::new(); + match stdin.read_line(&mut s) { + Ok(0) | Err(_) => None, + Ok(_) => Some(s), + } + } + } + } +} + pub async fn repl(base: &Path) -> anyhow::Result<()> { let lib = agents::load(base); let backends = harness::installed_cli_backends(); @@ -66,26 +117,22 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { println!(" ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝\x1b[0m"); println!(" {} agents loaded · detected logins: {}", lib.total(), if backends.is_empty() { "none (use API keys)".into() } else { backends.join(", ") }); - 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"); + println!(" Type \x1b[36m/help\x1b[0m to start, \x1b[36m/run\x1b[0m to launch, \x1b[36m/quit\x1b[0m to exit. (↑/↓ recalls commands)\n"); let mut s = Session::default(); - let mut history: Vec = Vec::new(); + let mut history: Vec = load_runs(base); + if !history.is_empty() { + println!(" loaded {} past run(s) — /runs to list\n", history.len()); + } + let mut reader = Reader::new(base); show(&s); - let stdin = std::io::stdin(); loop { - print!("{PROMPT}"); - std::io::stdout().flush().ok(); - let mut line = String::new(); - if stdin.read_line(&mut line).unwrap_or(0) == 0 { - println!(); - break; // EOF (Ctrl-D) - } + let Some(line) = reader.read() else { println!("\n bye."); break }; let line = line.trim(); if line.is_empty() { continue; } - // A bare line that isn't a command is treated as test instructions. if !line.starts_with('/') { s.instructions = Some(line.to_string()); println!(" focus set: {line}"); @@ -105,35 +152,18 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { } "/model" | "/models" => { if arg.is_empty() { - println!(" current: {}", s.models.join(", ")); + pick_models(&mut s); } else { s.models = arg.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect(); println!(" models: {}", s.models.join(", ")); } } - "/key" => { - // /key → sets the provider's env var for this session - let mut kp = arg.splitn(2, char::is_whitespace); - match (kp.next(), kp.next()) { - (Some(prov), Some(key)) if !key.trim().is_empty() => { - match harness::provider_for(prov) { - Some(p) => { - std::env::set_var(p.env_key, key.trim()); - s.subscription = false; - println!(" set {} (API mode)", p.env_key); - } - None => println!(" unknown provider '{prov}' (see /providers)"), - } - } - _ => println!(" usage: /key e.g. /key anthropic sk-ant-..."), - } - } + "/key" => key_cmd(&mut s, arg, &mut reader), "/sub" | "/subscription" => { s.subscription = !matches!(arg, "off" | "false" | "0" | "no"); println!(" subscription: {}", onoff(s.subscription)); } "/target" | "/url" => { - // target + repo can coexist → greybox. let t = if arg.starts_with("http") || arg.is_empty() { arg.to_string() } else { format!("https://{arg}") }; s.target = if t.is_empty() { None } else { Some(t) }; println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none)".into())); @@ -154,18 +184,12 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { s.instructions = if arg.is_empty() { None } else { Some(arg.to_string()) }; println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into())); } - "/mcp" => { - 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)); - } + "/mcp" => { 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: {}", 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); } + "/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, &mut history).await, + "/run" | "/go" => { run(base, &s, &mut history).await; save_runs(base, &history); } "/runs" | "/history" => list_runs(&history), "/results" => results(&history, arg), "/report" => open_report(&history, arg), @@ -177,17 +201,93 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { Ok(()) } +/// Arrow-key multi-select of models from the catalog (interactive terminals only). +fn pick_models(s: &mut Session) { + if !std::io::stdin().is_terminal() { + println!(" current: {} (use /model to set)", s.models.join(", ")); + return; + } + let mut ids: Vec = Vec::new(); + for p in harness::providers() { + for m in &p.models { + ids.push(format!("{}:{}", p.key, m)); + } + } + let defaults: Vec = ids.iter().map(|id| s.models.contains(id)).collect(); + match MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Select models (space toggles, ↑/↓ moves, enter confirms)") + .items(&ids) + .defaults(&defaults) + .interact_opt() + { + Ok(Some(idx)) if !idx.is_empty() => { + s.models = idx.into_iter().map(|i| ids[i].clone()).collect(); + println!(" models: {}", s.models.join(", ")); + } + _ => println!(" models unchanged: {}", s.models.join(", ")), + } +} + +/// Configure API keys based on the selected models: `/key` lists the providers +/// your models need (set/missing) and prompts for missing ones; `/key ` +/// sets one directly. +fn key_cmd(s: &mut Session, arg: &str, reader: &mut Reader) { + if !arg.is_empty() { + let mut kp = arg.splitn(2, char::is_whitespace); + if let (Some(prov), Some(key)) = (kp.next(), kp.next()) { + set_key(prov, key.trim(), s); + } else { + println!(" usage: /key e.g. /key anthropic sk-ant-..."); + } + return; + } + // No arg → walk the providers required by the selected models. + let provs: Vec = s.models.iter() + .map(|m| m.split(':').next().unwrap_or("").to_string()) + .collect::>().into_iter().collect(); + println!(" API keys for your selected models:"); + for prov in &provs { + let Some(p) = harness::provider_for(prov) else { continue }; + let set = std::env::var(p.env_key).map(|v| !v.is_empty()).unwrap_or(false); + let mark = if set { "✓ set" } else { "✗ missing" }; + println!(" {prov:<12} {} ({})", mark, p.env_key); + } + if std::io::stdin().is_terminal() { + for prov in &provs { + let Some(p) = harness::provider_for(prov) else { continue }; + if std::env::var(p.env_key).map(|v| !v.is_empty()).unwrap_or(false) { + continue; + } + if let Reader::Rl(ed, _) = reader { + match ed.readline(&format!(" paste {prov} key (blank to skip): ")) { + Ok(k) if !k.trim().is_empty() => set_key(prov, k.trim(), s), + _ => {} + } + } + } + } else { + println!(" (set with /key or export {{ENV}} before launch)"); + } +} + +fn set_key(prov: &str, key: &str, s: &mut Session) { + match harness::provider_for(prov) { + Some(p) => { + std::env::set_var(p.env_key, key); + s.subscription = false; + println!(" set {} (API mode)", p.env_key); + } + None => println!(" unknown provider '{prov}' (see /providers)"), + } +} + async fn run(base: &Path, s: &Session, history: &mut Vec) { - // 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) { (Some(r), Some(t)) => M::Grey { url: t.clone(), repo: r.clone() }, (Some(r), None) => M::White(r.clone()), (None, Some(t)) => M::Black(t.clone()), - _ => { - println!(" \x1b[31m✗ set a /target and/or /repo first.\x1b[0m"); - return; - } + _ => { println!(" \x1b[31m✗ set a /target and/or /repo first.\x1b[0m"); return; } }; let primary = match &m { M::Black(t) | M::White(t) => t.clone(), @@ -207,11 +307,7 @@ async fn run(base: &Path, s: &Session, history: &mut Vec) { } 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 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, @@ -222,29 +318,32 @@ async fn run(base: &Path, s: &Session, history: &mut Vec) { 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(), - }); + 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 runs_path(base: &Path) -> std::path::PathBuf { + base.join("data").join("repl_runs.json") +} +fn load_runs(base: &Path) -> Vec { + std::fs::read_to_string(runs_path(base)).ok() + .and_then(|t| serde_json::from_str(&t).ok()) + .unwrap_or_default() +} +fn save_runs(base: &Path, history: &[RunRecord]) { + let p = runs_path(base); + if let Some(dir) = p.parent() { std::fs::create_dir_all(dir).ok(); } + if let Ok(j) = serde_json::to_string_pretty(history) { std::fs::write(p, j).ok(); } +} + 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(); - } + if history.is_empty() { println!(" no runs yet — /run first."); return None; } + if arg.trim().is_empty() { return history.last(); } match arg.trim().parse::() { - 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 } + Ok(n) => history.iter().find(|r| r.id == n).or_else(|| { println!(" no run #{n} (have 1..{})", history.len()); None }), + Err(_) => { println!(" usage: /results "); None } } } @@ -255,13 +354,12 @@ fn sev_counts(f: &[Finding]) -> std::collections::BTreeMap<&str, usize> { } fn list_runs(history: &[RunRecord]) { - if history.is_empty() { println!(" no runs yet this session."); return; } - println!(" ┌─ session runs"); + if history.is_empty() { println!(" no runs yet."); return; } + println!(" ┌─ runs (this + past sessions)"); 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::>().join(" ") }; - println!(" │ #{:<2} {:<9} {:<40} {}", r.id, r.mode, trunc(&r.target, 40), sev); + let sev = if c.is_empty() { "0 findings".into() } else { c.iter().map(|(k, v)| format!("{k}:{v}")).collect::>().join(" ") }; + println!(" │ #{:<2} {:<9} {:<38} {}", r.id, r.mode, trunc(&r.target, 38), sev); } println!(" └─ /results · /report · /status "); } @@ -269,14 +367,9 @@ fn list_runs(history: &[RunRecord]) { 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; - } + 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, - }); + 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); @@ -289,12 +382,8 @@ 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 file = if pdf.is_file() { pdf } else { dir.join("report.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()), @@ -304,68 +393,48 @@ fn open_report(history: &[RunRecord], arg: &str) { 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) { + match std::fs::read_to_string(Path::new(&r.workdir).join("status.json")) { 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(", ")); - println!(" │ auth mode: {}", if s.subscription { "subscription (CLI login)" } else { "API key" }); let mode = match (&s.repo, &s.target) { (Some(_), Some(_)) => "greybox (code + live)", (Some(_), None) => "white-box (code)", (None, Some(_)) => "black-box (live)", _ => "(set /target and/or /repo)", }; + println!(" ┌─ session"); + println!(" │ models : {}", s.models.join(", ")); + println!(" │ auth mode: {}", if s.subscription { "subscription (CLI login)" } else { "API key" }); println!(" │ mode : {mode}"); println!(" │ target : {}", s.target.clone().unwrap_or_else(|| "(none)".into())); println!(" │ repo : {}", s.repo.clone().unwrap_or_else(|| "(none)".into())); println!(" │ auth : {}", s.auth.clone().unwrap_or_else(|| "(none)".into())); println!(" │ creds : {}", s.creds.clone().unwrap_or_else(|| "(none)".into())); println!(" │ focus : {}", s.instructions.clone().unwrap_or_else(|| "(none — tests everything)".into())); - println!(" │ mcp : {} votes: {} max-agents: {}", onoff(s.mcp), s.vote_n, s.max_agents); + println!(" │ opts : mcp={} offline={} votes={} max-agents={}", onoff(s.mcp), onoff(s.offline), s.vote_n, s.max_agents); println!(" └─ /run to launch"); } fn help() { - println!(" Commands:"); - println!(" /model a:b[,c:d] set model panel (1st primary; rest fail over + vote)"); + 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 set a provider API key (switches to API mode)"); + 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 local repo (repo+target = greybox: code + live)"); - println!(" /auth auth to send (e.g. 'Authorization: Bearer ' or 'Cookie: s=..')"); - println!(" /creds load credentials (jwt/header/cookie/login) for authenticated tests"); - println!(" /focus 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 validator votes per finding"); - println!(" /agents 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!(" /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!(" /mcp on|off Playwright MCP browser /offline on|off self-test"); + println!(" /votes /agents "); + println!(" /run launch · /runs /results [n] /report [n] /status [n]"); println!(" /quit exit"); - println!(); - println!(" Example:"); - println!(" /model anthropic:claude-opus-4-8"); - println!(" /target http://testphp.vulnweb.com/"); - println!(" find injection and broken access control"); - println!(" /run"); } -fn onoff(b: bool) -> &'static str { - if b { "on" } else { "off" } -} +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)]) } } diff --git a/neurosploit-rs/crates/harness/src/models.rs b/neurosploit-rs/crates/harness/src/models.rs index 42cac8c..93b5a6c 100644 --- a/neurosploit-rs/crates/harness/src/models.rs +++ b/neurosploit-rs/crates/harness/src/models.rs @@ -24,12 +24,12 @@ pub fn providers() -> Vec { vec![ Provider { key: "anthropic", label: "Anthropic Claude", base_url: "https://api.anthropic.com/v1", env_key: "ANTHROPIC_API_KEY", kind: "cli", models: vec!["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"] }, - Provider { key: "openai", label: "OpenAI", base_url: "https://api.openai.com/v1", env_key: "OPENAI_API_KEY", kind: "cli", - models: vec!["gpt-5.1", "o4"] }, + Provider { key: "openai", label: "OpenAI (ChatGPT)", base_url: "https://api.openai.com/v1", env_key: "OPENAI_API_KEY", kind: "cli", + models: vec!["gpt-5.1", "gpt-5.1-codex", "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"] }, + models: vec!["gemini-3-pro", "gemini-2.5-pro", "gemini-2.5-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", diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index d04ab36..fa6b74a 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -195,7 +195,16 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender) -> Vec { + if confirmed.is_empty() { + return vec![]; + } + let summary: String = confirmed.iter().take(20) + .map(|f| format!("- [{}] {} @ {} ({})", f.severity, f.title, f.endpoint, f.cwe)) + .collect::>().join("\n"); + let _ = tx.send(format!("chaining {} confirmed finding(s) for deeper impact…", confirmed.len())).await; + let recon_ctx: String = recon.chars().take(2500).collect(); + let user = format!( + "AUTHORIZED engagement on {target}.\n\n{directives}{react}{doctrine}\ + CONFIRMED FINDINGS TO CHAIN:\n{summary}\n\nRecon:\n{recon_ctx}\n\n\ + Chain these into deeper impact and PROVE it. Reply ONLY a JSON array of NEW findings \ + (may be []): {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence}}.", + react = REACT_DOCTRINE, doctrine = tool_doctrine(pool.mcp_config.is_some()), + ); + match pool.complete_routed(Task::Exploit, CHAIN_SYS, &user).await { + Ok((m, text)) => { + let f = extract_findings(&text, "chain"); + let _ = tx.send(format!("chain via {} → {} new candidate(s)", m.label(), f.len())).await; + f + } + Err(e) => { let _ = tx.send(format!("chaining failed: {e}")).await; vec![] } + } +} + // --------------------------------------------------------------------------- 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.";