From 969af20a8e1ac66cb9bde1eee8236af8a302e58a Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Wed, 24 Jun 2026 21:52:53 -0300 Subject: [PATCH] =?UTF-8?q?v3.5.1:=20Mission=20Control=20TUI=20(ratatui)?= =?UTF-8?q?=20=E2=80=94=20concurrent=20panels=20+=20composer=20active=20du?= =?UTF-8?q?ring=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `neurosploit tui [--repo ..] [--model ..] [--subscription] [--mcp] [--focus ..]` - Concurrent ratatui UI driven by the engagement's live event stream: * fixed status header: target · mode · model · phase · elapsed · token/cost · findings · ⏸ * live activity feed (color-coded: commands, recon, findings, errors) * live Findings panel (severity-styled) and a Targets map (hosts → state) * composer input that stays active WHILE the runner streams — local, non-blocking answers: `summary`/`what` (partial summary), `pause` (graceful stop), `errors` (filter), `clear`, or free-text notes. - Engagement runs as a tokio task; UI drains an mpsc channel each ~120ms tick. Esc/Ctrl-C requests a graceful stop; report is generated on exit (status stopped/complete). - Terminal setup before task spawn → clean error on non-TTY, no detached run. - README documents the TUI mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 + neurosploit-rs/Cargo.lock | 310 ++++++++++++++++++++++++++++++++- neurosploit-rs/app/Cargo.toml | 2 + neurosploit-rs/app/src/main.rs | 42 +++++ neurosploit-rs/app/src/tui.rs | 303 ++++++++++++++++++++++++++++++++ 5 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 neurosploit-rs/app/src/tui.rs diff --git a/README.md b/README.md index 1386085..3d6c154 100755 --- a/README.md +++ b/README.md @@ -93,6 +93,10 @@ neurosploit # or one-liner (subscription login, no API key needed): neurosploit run http://testphp.vulnweb.com/ --subscription --model anthropic:claude-opus-4-8 -v + +# 🛰 Mission Control TUI — live panels (header/feed/findings/targets) + a composer +# you can type in WHILE the run streams (summary · pause · errors · notes): +neurosploit tui http://testphp.vulnweb.com/ --subscription --model anthropic:claude-opus-4-8 --mcp ``` No login? Use an **API key** instead — see [Authentication](#authentication--run-via-api-key-or-subscription). diff --git a/neurosploit-rs/Cargo.lock b/neurosploit-rs/Cargo.lock index b3c1b7f..fcb0566 100644 --- a/neurosploit-rs/Cargo.lock +++ b/neurosploit-rs/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -97,6 +103,21 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.65" @@ -180,6 +201,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.15.11" @@ -193,6 +228,65 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dialoguer" version = "0.11.0" @@ -217,6 +311,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -229,6 +329,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -258,8 +364,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix", - "windows-sys 0.52.0", + "rustix 1.1.4", + "windows-sys 0.59.0", ] [[package]] @@ -268,6 +374,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -392,6 +504,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -587,6 +710,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -608,6 +737,28 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -620,6 +771,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -643,6 +803,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -670,6 +836,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -689,6 +864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -699,9 +875,11 @@ version = "3.5.1" dependencies = [ "anyhow", "clap", + "crossterm", "dialoguer", "futures", "neurosploit-harness", + "ratatui", "rustyline", "serde", "serde_json", @@ -778,6 +956,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -926,6 +1110,27 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "instability", + "itertools", + "lru", + "paste", + "strum", + "strum_macros", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.1.14", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1022,6 +1227,19 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1031,7 +1249,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -1186,6 +1404,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1224,12 +1463,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1276,8 +1543,8 @@ dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix", - "windows-sys 0.52.0", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -1465,6 +1732,17 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -1625,6 +1903,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1634,6 +1928,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/neurosploit-rs/app/Cargo.toml b/neurosploit-rs/app/Cargo.toml index 84e26fa..c1e0bdb 100644 --- a/neurosploit-rs/app/Cargo.toml +++ b/neurosploit-rs/app/Cargo.toml @@ -18,3 +18,5 @@ futures.workspace = true clap = { version = "4", features = ["derive"] } rustyline = "14" dialoguer = "0.11" +ratatui = "0.28" +crossterm = "0.28" diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 81343d7..f4affd7 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -1,6 +1,7 @@ //! NeuroSploit v3.5.1 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`). mod repl; +mod tui; use clap::{Parser, Subcommand}; use harness::{agents, models::ModelRef, pool::ModelPool, types::RunConfig, RunOutput}; @@ -108,6 +109,27 @@ enum Cmd { #[arg(short, long)] verbose: bool, }, + /// Mission Control TUI: concurrent panels (header/feed/findings/targets) with + /// a composer active during the run. Black-box (URL) or, with --repo, greybox. + Tui { + url: String, + #[arg(long = "model")] + models: Vec, + #[arg(long)] + repo: Option, + #[arg(long)] + creds: Option, + #[arg(long)] + focus: Option, + #[arg(long, default_value_t = 0)] + max_agents: usize, + #[arg(long, default_value_t = 3)] + vote_n: usize, + #[arg(long)] + subscription: bool, + #[arg(long)] + mcp: bool, + }, /// Show agent library counts. Agents, /// List providers and models. @@ -214,10 +236,30 @@ async fn main() -> anyhow::Result<()> { let out = run_greybox_engagement(&base, cfg, mcp).await?; print_findings(&out); } + Cmd::Tui { url, models, repo, creds, focus, max_agents, vote_n, subscription, mcp } => { + let url = if url.starts_with("http") { url } else { format!("https://{url}") }; + let mut cfg = RunConfig::new(&url); + cfg.max_agents = max_agents; + cfg.vote_n = vote_n; + cfg.subscription = subscription; + cfg.instructions = focus; + cfg.repo = repo.clone(); + if !models.is_empty() { + cfg.models = models; + } + apply_creds(&mut cfg, creds.as_deref()).await; + let mode = if repo.is_some() { Mode::Grey } else { Mode::Black }; + tui::run(&base, cfg, mcp, mode).await?; + } } Ok(()) } +// Helpers the TUI module reuses. +pub(crate) fn now_ts_pub() -> u64 { now_ts() } +pub(crate) fn sanitize_pub(s: &str) -> String { sanitize(s) } +pub(crate) fn write_status_pub(workdir: &Path, state: &str, extra: &str) { write_status(workdir, state, extra); } + /// Load a creds.yaml into the run config. Direct material (jwt/header/cookie) is /// used as-is; a `login:` flow is EXECUTED now (real HTTP) to capture a live /// session cookie/token. If the auto-login fails, fall back to instructing the diff --git a/neurosploit-rs/app/src/tui.rs b/neurosploit-rs/app/src/tui.rs new file mode 100644 index 0000000..8395926 --- /dev/null +++ b/neurosploit-rs/app/src/tui.rs @@ -0,0 +1,303 @@ +//! NeuroSploit v3.5.1 — TUI "Mission Control" mode. +//! +//! Concurrent panels that update live while the engagement runs in the +//! background, with a composer input that stays active during execution: +//! +//! ┌ status header (target · mode · phase · elapsed · tokens · findings) ┐ +//! │ live activity feed │ findings (live) │ +//! │ (recon/exploit/tool/command) ├───────────────────────────────────┤ +//! │ │ targets / queue │ +//! └ composer: ask 'summary', 'pause', 'errors', or notes … ────────────┘ +//! +//! The engagement runs as a tokio task streaming tagged events over an mpsc +//! channel; the UI drains them each tick. The composer answers locally +//! (summary / what-found / errors / pause) WITHOUT stopping the runner. + +use crate::Mode; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use crossterm::{execute, terminal}; +use harness::{agents, models::ModelRef, pool::ModelPool, types::RunConfig}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; +use std::collections::VecDeque; +use std::io::stdout; +use std::path::Path; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +struct Ui { + target: String, + models: String, + mode: &'static str, + phase: String, + started: Instant, + feed: VecDeque, + findings: Vec<(String, String, String)>, // sev, title, endpoint + targets: Vec<(String, String)>, // host, state + tin: u64, + tout: u64, + cost: f64, + input: String, + filter_errors: bool, + done: bool, + paused: bool, +} + +impl Ui { + fn new(target: &str, models: &str, mode: &'static str) -> Self { + let host = target.replace("https://", "").replace("http://", ""); + let host = host.split('/').next().unwrap_or(&host).to_string(); + Ui { + target: target.into(), models: models.into(), mode, + phase: "starting".into(), started: Instant::now(), + feed: VecDeque::new(), findings: vec![], + targets: vec![(host, "🔄 running".into())], + tin: 0, tout: 0, cost: 0.0, input: String::new(), + filter_errors: false, done: false, paused: false, + } + } + + fn ingest(&mut self, raw: String) { + let line = raw.trim_end().to_string(); + let low = line.to_lowercase(); + // phase tracking + if low.contains("recon") { self.phase = "🔍 recon".into(); } + else if low.contains("planning") || low.contains("selected") || low.contains("selection") { self.phase = "🧭 planning".into(); } + else if low.starts_with("exploit") || low.contains("launching agent") || low.starts_with("analyze") { 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(); } + + // live findings + if let Some(rest) = line.strip_prefix("finding: ") { + // "[sev] title @ endpoint" + if let Some(b) = rest.strip_prefix('[') { + if let Some((sev, tail)) = b.split_once(']') { + let (title, ep) = tail.trim().split_once(" @ ").unwrap_or((tail.trim(), "")); + self.findings.push((sev.to_string(), title.to_string(), ep.to_string())); + self.note_target_from(ep); + } + } + return; + } + // token telemetry + if let Some(rest) = line.strip_prefix("@").and_then(|s| s.split_once(' ')).map(|(_, r)| r).filter(|r| r.starts_with("tokens:")).or_else(|| line.strip_prefix("tokens: ").map(|_| line.as_str())) { + for part in rest.split_whitespace() { + if let Some(v) = part.strip_prefix("in=") { self.tin += v.parse().unwrap_or(0); } + else if let Some(v) = part.strip_prefix("out=") { self.tout += v.parse().unwrap_or(0); } + else if let Some(v) = part.strip_prefix("cost=$") { self.cost += v.parse().unwrap_or(0.0); } + } + } + let is_err = low.contains("fail") || low.contains("error") || low.starts_with('✗'); + if self.filter_errors && !is_err { return; } + self.feed.push_back(line); + while self.feed.len() > 500 { self.feed.pop_front(); } + } + + fn note_target_from(&mut self, endpoint: &str) { + let host = endpoint.replace("https://", "").replace("http://", ""); + let host = host.split('/').next().unwrap_or("").to_string(); + if !host.is_empty() && !self.targets.iter().any(|(h, _)| h == &host) { + self.targets.push((host, "🔄 testing".into())); + } + } + + /// Composer command (local, non-blocking). Returns feed lines to show. + fn composer(&mut self, cmd: &str) -> Vec { + let c = cmd.trim().to_lowercase(); + match c.as_str() { + "" => vec![], + "pause" | "/pause" | "stop" | "/stop" => { self.paused = true; vec!["⏸ pausing — finishing in-flight work, no new agents".into()] } + "errors" | "/errors" => { self.filter_errors = !self.filter_errors; vec![format!("filter errors: {}", self.filter_errors)] } + "clear" | "/clear" => { self.feed.clear(); vec![] } + "summary" | "/summary" | "what" | "o que" | "resumo" => self.summary(), + "findings" | "/findings" => self.summary(), + "quit" | "/quit" | "exit" => { self.done = true; vec![] } + other => vec![format!("noted: {other}")], + } + } + + fn summary(&self) -> Vec { + let mut by: std::collections::BTreeMap<&str, usize> = Default::default(); + for (s, _, _) in &self.findings { *by.entry(s.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(" ") }; + let mut out = vec![format!("── partial summary: {} finding(s) [{}] · phase {} ──", self.findings.len(), sev, self.phase)]; + for (s, t, _) in self.findings.iter().rev().take(5) { out.push(format!(" • [{s}] {t}")); } + out + } +} + +/// Run the Mission-Control TUI for an engagement. +pub async fn run(base: &Path, mut cfg: RunConfig, mcp: bool, mode: Mode) -> anyhow::Result<()> { + let lib = agents::load(base); + let run_id = format!("ns-{}-{}", crate::now_ts_pub(), crate::sanitize_pub(&cfg.target)); + let workdir = base.join("runs").join(&run_id); + std::fs::create_dir_all(&workdir).ok(); + cfg.workdir = Some(workdir.display().to_string()); + cfg.rl_path = Some(base.join("data").join("rl_state_rs.json").display().to_string()); + cfg.verbose = true; + + let mcp_config = if mcp && cfg.subscription { + harness::ensure_playwright_mcp().ok().and_then(|_| harness::write_mcp_config(&workdir, None).ok()) + .map(|p| p.display().to_string()) + } 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::(512); + let models = cfg.models.join(", "); + let mode_s = match mode { Mode::White => "white-box", Mode::Grey => "greybox", Mode::Black => "black-box" }; + let target_s = cfg.target.clone(); + + // ---- terminal setup FIRST: on a non-TTY this errors before we spawn any + // live engagement, so we never detach a running task. ---- + terminal::enable_raw_mode()?; + execute!(stdout(), terminal::EnterAlternateScreen)?; + let mut term = Terminal::new(CrosstermBackend::new(stdout()))?; + let mut ui = Ui::new(&target_s, &models, mode_s); + + let mut 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::Black => harness::run(cfg, &lib, &pool, tx).await, + } + }); + + let out; + loop { + // drain engagement events + while let Ok(line) = rx.try_recv() { ui.ingest(line); } + // engagement finished? + if task.is_finished() { + ui.done = true; + ui.phase = "✓ complete".into(); + if let Some((_, st)) = ui.targets.get_mut(0) { *st = "✅ done".into(); } + } + draw(&mut term, &ui)?; + + // input (100ms tick keeps the UI live while the runner works) + if event::poll(Duration::from_millis(120))? { + if let Event::Key(k) = event::read()? { + let ctrl_c = k.modifiers.contains(KeyModifiers::CONTROL) && k.code == KeyCode::Char('c'); + match k.code { + KeyCode::Esc => { cancel.store(true, Ordering::Relaxed); if ui.done { break; } ui.paused = true; } + KeyCode::Char('c') if ctrl_c => { cancel.store(true, Ordering::Relaxed); if ui.done { break; } ui.paused = true; } + KeyCode::Enter => { + let line = std::mem::take(&mut ui.input); + if matches!(line.trim(), "quit" | "/quit" | "exit") && ui.done { break; } + let lines = ui.composer(&line); + if ui.paused { cancel.store(true, Ordering::Relaxed); } + for l in lines { ui.feed.push_back(l); } + } + KeyCode::Backspace => { ui.input.pop(); } + KeyCode::Char(c) => { ui.input.push(c); } + _ => {} + } + } + } + if ui.done && task.is_finished() && ui.input.is_empty() { + // brief grace so the final frame is visible; exit on next Esc/Enter handled above + } + } + + out = (&mut task).await.unwrap_or_default(); + + // ---- restore terminal ---- + execute!(stdout(), terminal::LeaveAlternateScreen)?; + terminal::disable_raw_mode()?; + + // generate report unless discarded; print a plain summary after leaving the TUI + match harness::report::typst_report(&out.target, &out.findings, &workdir) { + Ok(p) => println!(" report → {}", p.display()), + Err(_) => {} + } + crate::write_status_pub(&workdir, if cancel.load(Ordering::Relaxed) { "stopped" } else { "complete" }, ""); + println!(" ✓ {} validated finding(s) · {}", out.findings.len(), workdir.display()); + Ok(()) +} + +fn sevstyle(s: &str) -> Style { + match s { + "Critical" => Style::new().fg(Color::Red).bold(), + "High" => Style::new().fg(Color::Rgb(251, 146, 60)), + "Medium" => Style::new().fg(Color::Yellow), + "Low" => Style::new().fg(Color::Cyan), + _ => Style::new().fg(Color::Gray), + } +} + +fn draw(term: &mut Terminal>, ui: &Ui) -> anyhow::Result<()> { + term.draw(|f| { + let root = Layout::vertical([ + Constraint::Length(3), // header + Constraint::Min(5), // body + Constraint::Length(3), // composer + ]).split(f.area()); + + // ── header ── + let el = ui.started.elapsed().as_secs(); + let accent = Style::new().fg(Color::Rgb(139, 92, 246)).bold(); + let header = Line::from(vec![ + Span::styled(" 🧠 NeuroSploit ", accent), + Span::raw(format!("│ {} ", ui.target)), + Span::styled(format!("│ {} ", ui.mode), Style::new().fg(Color::Magenta)), + Span::styled(format!("│ {} ", short_models(&ui.models)), Style::new().fg(Color::DarkGray)), + Span::styled(format!("│ {} ", ui.phase), Style::new().fg(Color::Cyan)), + Span::raw(format!("│ {:02}:{:02} ", el / 60, el % 60)), + Span::styled(format!("│ {} findings ", ui.findings.len()), Style::new().fg(Color::Yellow)), + Span::raw(format!("│ 🪙 {}/{} ${:.3} ", ui.tin, ui.tout, ui.cost)), + if ui.paused { Span::styled("│ ⏸ stopping ", Style::new().fg(Color::Red)) } else { Span::raw("") }, + ]); + f.render_widget(Paragraph::new(header).block(Block::default().borders(Borders::ALL) + .title(" Mission Control ").border_style(accent)), root[0]); + + // ── body: feed | (findings / targets) ── + let body = Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]).split(root[1]); + + let feed_h = body[0].height.saturating_sub(2) as usize; + let feed: Vec = ui.feed.iter().rev().take(feed_h).rev() + .map(|l| ListItem::new(feed_span(l))).collect(); + f.render_widget(List::new(feed).block(Block::default().borders(Borders::ALL) + .title(format!(" Activity{} ", if ui.filter_errors { " [errors]" } else { "" }))), body[0]); + + let right = Layout::vertical([Constraint::Percentage(60), Constraint::Percentage(40)]).split(body[1]); + let finds: Vec = ui.findings.iter().rev().take(right[0].height.saturating_sub(2) as usize) + .map(|(s, t, _)| ListItem::new(Line::from(vec![ + Span::styled(format!("[{s}] "), sevstyle(s)), Span::raw(t.clone())]))).collect(); + f.render_widget(List::new(finds).block(Block::default().borders(Borders::ALL) + .title(format!(" Findings ({}) ", ui.findings.len()))), right[0]); + + let tg: Vec = ui.targets.iter() + .map(|(h, st)| ListItem::new(format!("{st} {h}"))).collect(); + f.render_widget(List::new(tg).block(Block::default().borders(Borders::ALL).title(" Targets ")), right[1]); + + // ── composer ── + let hint = if ui.done { "engagement done — type quit/Esc to exit · summary" } + else { "composer (runner active): summary · pause · errors · clear · or a note" }; + let comp = Paragraph::new(Line::from(vec![ + Span::styled("› ", accent), Span::raw(&ui.input), + Span::styled("▏", Style::new().fg(Color::Rgb(139, 92, 246))), + ])).block(Block::default().borders(Borders::ALL).title(format!(" {hint} "))).wrap(Wrap { trim: false }); + f.render_widget(comp, root[2]); + })?; + Ok(()) +} + +fn short_models(m: &str) -> String { + // show just the first model's name, compactly + m.split(',').next().unwrap_or(m).split(':').next_back().unwrap_or(m).trim().to_string() +} + +fn feed_span(l: &str) -> Line<'static> { + let low = l.to_lowercase(); + let (color, s) = if l.starts_with("finding:") || l.contains("possible finding") { (Color::Yellow, l) } + else if l.starts_with("notify:") || l.contains('🔔') { (Color::Cyan, l) } + else if low.contains("fail") || low.contains("error") || l.starts_with('✗') { (Color::Red, l) } + else if low.contains("exec:") || low.contains("command") || low.contains("curl") { (Color::Rgb(230, 180, 100), l) } + else if low.contains("recon") || low.contains("vote") || low.contains("chain") { (Color::Cyan, l) } + else { (Color::Gray, l) }; + Line::from(Span::styled(s.to_string(), Style::new().fg(color))) +}