From 21a62c95e5824ec227489b010743b2f7702b2676 Mon Sep 17 00:00:00 2001 From: Joas A Santos <34966120+JoasASantos@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:18:12 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20natural-language=20REPL=20=E2=80=94=20h?= =?UTF-8?q?ands-free=20config=20in=20any=20language=20(hybrid)=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type a plain sentence (no slash) and NeuroSploit configures the session and can launch — no manual flags. Hybrid parser: - Deterministic fast-path (0 tokens): extracts target/host, model shorthands (opus/sonnet/gpt/gemini/grok), run verbs and keyworded clauses (focus / objective / out-of-scope / auth) across PT/EN/ES. - Model fallback: when the phrase is ambiguous, the configured model structures it into a JSON intent — works in any language. Intent maps onto target/repo/models/focus/objective/out_of_scope/auth/ scope; if the request says "run/roda/prueba" it falls through to /run. Falls back to setting focus when nothing structured is found or offline. e.g. "testa https://loja.com com opus, foco em SQLi, fora de escopo /admin, roda". Tests cover PT/EN/ES fast-path, clause parsing, host heuristic, alias resolution, and the ambiguity gate. Claude-Session: https://claude.ai/code/session_018BGLy4j5qsqqid6CoovowC Co-authored-by: Claude Opus 4.8 --- neurosploit-rs/app/src/repl.rs | 329 ++++++++++++++++++++++++++++++++- 1 file changed, 320 insertions(+), 9 deletions(-) diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index 68798a6..6bd98d4 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -373,7 +373,8 @@ 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 start, \x1b[36m/run\x1b[0m to launch, \x1b[36m/quit\x1b[0m to exit. (↑/↓ recalls commands)\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)"); + println!(" \x1b[2mOr just describe it in any language:\x1b[0m \x1b[36mtesta https://loja.com com opus, foco em SQLi, roda\x1b[0m\n"); let mut s = Session::default(); let resumed = load_session(&mut s); @@ -455,16 +456,20 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { if line.is_empty() { continue; } - if !line.starts_with('/') { + // Natural-language input (no leading '/'): interpret it, configure the + // session hands-free, and — if the phrase asked to run — fall through to + // the /run handler. Hybrid: a zero-token deterministic parse handles the + // common shapes; anything ambiguous is resolved by the model (any language). + let (cmd, arg): (String, String) = if line.starts_with('/') { + let mut parts = line.splitn(2, char::is_whitespace); + (parts.next().unwrap_or("").to_string(), parts.next().unwrap_or("").trim().to_string()) + } else { let attached = expand_ats(line, &mut s); - s.instructions = Some(line.to_string()); - println!(" focus set: {line}"); if attached > 0 { println!(" ({attached} @attachment(s) added to context)"); } - continue; - } - let mut parts = line.splitn(2, char::is_whitespace); - let cmd = parts.next().unwrap_or(""); - let arg = parts.next().unwrap_or("").trim(); + let run = handle_nl(line, &mut s).await; + if run { ("/run".to_string(), String::new()) } else { continue } + }; + let (cmd, arg) = (cmd.as_str(), arg.as_str()); match cmd { "/help" | "/?" => help(), "/show" | "/config" => show(&s), @@ -1694,6 +1699,10 @@ fn help() { 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[2mNATURAL LANGUAGE (any language — just type, no slash)\x1b[0m"); + println!(" e.g. \x1b[36mtesta https://loja.com com opus, foco em SQLi, fora de escopo /admin, roda\x1b[0m"); + println!(" \x1b[2mconfigures target/models/focus/objective/out-of-scope and can launch — hands-free\x1b[0m"); + println!("\n \x1b[2mTARGET & SCOPE\x1b[0m"); h("/onboard", "guided setup: pick scope (web · infra · cloud · ai/llm · skills/n8n)"); h("/target ", "black-box target / AI endpoint / host (comma-separated = multi-target)"); @@ -1752,6 +1761,259 @@ fn help() { println!(" \x1b[2m↑/↓ history · Tab completes commands & @paths · Ctrl-A/E/K edit · Ctrl-O full cmd · \\ for multiline\x1b[0m\n"); } +// ===== Natural-language command interpreter (hybrid) ===== + +/// A parsed engagement intent extracted from a natural-language line. +#[derive(Default)] +struct Intent { + target: Option, + repo: Option, + models: Vec, + focus: Option, + objective: Option, + out_of_scope: Option, + auth: Option, + scope: Option<&'static str>, + run: bool, +} + +impl Intent { + fn is_empty(&self) -> bool { + self.target.is_none() && self.repo.is_none() && self.models.is_empty() + && self.focus.is_none() && self.objective.is_none() && self.out_of_scope.is_none() + && self.auth.is_none() && self.scope.is_none() && !self.run + } + /// Fill any field this intent is missing from `other` (deterministic wins). + fn merge_from(&mut self, other: Intent) { + if self.target.is_none() { self.target = other.target; } + if self.repo.is_none() { self.repo = other.repo; } + if self.models.is_empty() { self.models = other.models; } + if self.focus.is_none() { self.focus = other.focus; } + if self.objective.is_none() { self.objective = other.objective; } + if self.out_of_scope.is_none() { self.out_of_scope = other.out_of_scope; } + if self.auth.is_none() { self.auth = other.auth; } + if self.scope.is_none() { self.scope = other.scope; } + self.run = self.run || other.run; + } +} + +/// Resolve a natural-language line into session config. Deterministic fast-path +/// first (0 tokens); if the phrase is ambiguous and a model is available, ask it +/// to structure the request (works in any language). Returns whether to run now. +async fn handle_nl(line: &str, s: &mut Session) -> bool { + let (mut intent, confident) = parse_intent_fast(line); + if !confident && !s.offline { + if let Some(mi) = parse_intent_model(line, s).await { + intent.merge_from(mi); + } + } + if intent.is_empty() { + // Nothing structured found → treat the whole line as focus (old behavior). + s.instructions = Some(line.to_string()); + println!(" focus set: {line}"); + return false; + } + apply_intent(s, intent) +} + +/// Apply an intent to the session, print a summary, and return `run`. +fn apply_intent(s: &mut Session, intent: Intent) -> bool { + let mut set = Vec::new(); + if let Some(t) = intent.target { + let t = if t.starts_with("http") || t.contains("://") { t } else { format!("https://{t}") }; + s.target = Some(t.clone()); set.push(format!("target={t}")); + } + if let Some(r) = intent.repo { s.repo = Some(r.clone()); set.push(format!("repo={r}")); } + if !intent.models.is_empty() { + let m = resolve_model_aliases(&intent.models); + if !m.is_empty() { s.models = m.clone(); set.push(format!("models={}", m.join(","))); } + } + if let Some(sc) = intent.scope { s.scope = sc; set.push(format!("scope={sc}")); } + if let Some(f) = intent.focus { s.instructions = Some(f.clone()); set.push(format!("focus=\"{f}\"")); } + if let Some(o) = intent.objective { s.objective = Some(o.clone()); set.push(format!("objective=\"{o}\"")); } + if let Some(x) = intent.out_of_scope { s.out_of_scope = Some(x.clone()); set.push(format!("out-of-scope=\"{x}\"")); } + if let Some(a) = intent.auth { let a = normalize_auth(&a); s.auth = Some(a.clone()); set.push("auth set".into()); let _ = a; } + if set.is_empty() { + println!(" \x1b[2m(understood — nothing to change)\x1b[0m"); + } else { + println!(" \x1b[36m⇢ configured\x1b[0m {}", set.join(" · ")); + } + if intent.run { + if s.target.is_none() && s.repo.is_none() { + println!(" \x1b[33m! set a target/repo first — nothing to run yet.\x1b[0m"); + return false; + } + println!(" \x1b[1;35m▶ launching\x1b[0m …"); + } + intent.run +} + +/// Deterministic, zero-token parse for the common phrasings (PT/EN/ES). Returns +/// `(intent, confident)`; `confident=false` means hand off to the model. +fn parse_intent_fast(line: &str) -> (Intent, bool) { + let mut it = Intent::default(); + let low = line.to_lowercase(); + + // URL or bare host. + if let Some(u) = find_url(line) { + if u.contains("://") || looks_like_host(&u) { it.target = Some(u); } + } + // Model shorthands present anywhere. + for alias in ["opus", "sonnet", "haiku", "gpt", "chatgpt", "gemini", "grok"] { + if word_present(&low, alias) && !it.models.iter().any(|m| m == alias) { + it.models.push(alias.to_string()); + } + } + // Run verbs (any of the three languages). + const RUN_VERBS: &[&str] = &[ + "run", "test", "scan", "go", "launch", "execute", + "roda", "rode", "rodar", "testa", "teste", "testar", "escaneia", "escanear", + "executa", "executar", "varre", "varrer", "analisa", "analise", "ataca", "atacar", + "prueba", "probar", "escanea", "ejecuta", "corre", "lanza", "lanzar", + ]; + if RUN_VERBS.iter().any(|v| word_present(&low, v)) { it.run = true; } + + // Keyworded clauses: split on commas/semicolons and classify each chunk. + let mut residue = 0usize; + for chunk in line.split([',', ';', '\n']) { + let c = chunk.trim(); + if c.is_empty() { continue; } + let cl = c.to_lowercase(); + if let Some(rest) = after_any(&cl, c, &["fora de escopo", "fora do escopo", "out of scope", "out-of-scope", "fuera de alcance", "não teste", "nao teste", "não testar", "nao testar", "don't test", "do not test", "exclua", "excluir", "excluye"]) { + if !rest.is_empty() { it.out_of_scope = Some(join_opt(it.out_of_scope.take(), rest)); continue; } + } + if let Some(rest) = after_any(&cl, c, &["objetivo", "objective", "meta", "contexto", "context", "goal"]) { + if !rest.is_empty() { it.objective = Some(rest.to_string()); continue; } + } + if let Some(rest) = after_any(&cl, c, &["foco em", "foca em", "foco", "focus on", "focus", "enfoque en", "enfoque", "concentre em", "concentra em", "prioriza", "priorize"]) { + if !rest.is_empty() { it.focus = Some(join_opt(it.focus.take(), rest)); continue; } + } + if let Some(rest) = after_any(&cl, c, &["auth", "authorization", "cookie", "bearer", "token", "header"]) { + if !rest.is_empty() { it.auth = Some(c.to_string()); let _ = rest; continue; } + } + // Chunk that carried the URL / model / run verb is accounted for. + let carried = it.target.as_deref().map(|t| c.contains(t.trim_start_matches("https://").trim_start_matches("http://")) || cl.contains("http")).unwrap_or(false) + || RUN_VERBS.iter().any(|v| word_present(&cl, v)) + || ["opus","sonnet","haiku","gpt","chatgpt","gemini","grok"].iter().any(|a| word_present(&cl, a)); + if !carried { residue += c.split_whitespace().count(); } + } + // Confident when we structured something and no meaningful unclassified words remain. + let confident = !it.is_empty() && residue <= 2; + (it, confident) +} + +/// Ask the configured model to structure a free-form request into JSON. Any +/// language. Returns None on any failure (caller falls back gracefully). +async fn parse_intent_model(line: &str, s: &Session) -> Option { + let refs: Vec = s.models.iter().map(|m| ModelRef::parse(m)).collect(); + if refs.is_empty() { return None; } + let pool = harness::pool::ModelPool::with_auth(refs, 1, s.subscription, None); + let sys = "You convert a penetration tester's natural-language request (in ANY language) \ + into a compact JSON object that configures a scan. Keys (include ONLY those the user \ + expressed): target (url or host), repo (path/github), models (array of short names like \ + opus/sonnet/gpt/gemini/grok), focus (what to prioritise), objective (goal/context), \ + out_of_scope (exclusions), auth (header/cookie/token verbatim), scope (one of web|infra|cloud|ai|skills), \ + run (true only if the user clearly asked to start/execute now). Reply with ONLY the JSON object, no prose."; + let user = format!("Request: {line}"); + let (_, text) = pool.complete(sys, &user).await.ok()?; + let slice = { + let a = text.find('{')?; let b = text.rfind('}')?; + if b > a { &text[a..=b] } else { return None } + }; + let v: serde_json::Value = serde_json::from_str(slice).ok()?; + let o = v.as_object()?; + let gs = |k: &str| o.get(k).and_then(|x| x.as_str()).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()); + let mut it = Intent { + target: gs("target"), + repo: gs("repo"), + focus: gs("focus"), + objective: gs("objective"), + out_of_scope: gs("out_of_scope"), + auth: gs("auth"), + run: o.get("run").and_then(|x| x.as_bool()).unwrap_or(false), + ..Default::default() + }; + if let Some(sc) = gs("scope") { + it.scope = match sc.as_str() { + "web" => Some("web"), "infra" => Some("infra"), "cloud" => Some("cloud"), + "ai" => Some("ai"), "skills" => Some("skills"), _ => None, + }; + } + match o.get("models") { + Some(serde_json::Value::Array(a)) => { + for m in a { if let Some(t) = m.as_str() { if !t.trim().is_empty() { it.models.push(t.trim().to_string()); } } } + } + Some(serde_json::Value::String(t)) if !t.trim().is_empty() => it.models.push(t.trim().to_string()), + _ => {} + } + if it.is_empty() { None } else { Some(it) } +} + +/// Map short model names (opus/gpt/gemini/...) to concrete `provider:model` ids +/// from the catalog. Passes through anything already in `provider:model` form. +fn resolve_model_aliases(names: &[String]) -> Vec { + let catalog: Vec = harness::providers().into_iter() + .flat_map(|p| p.models.iter().map(move |m| format!("{}:{}", p.key, m)).collect::>()) + .collect(); + let mut out = Vec::new(); + for n in names { + let nl = n.to_lowercase(); + if nl.contains(':') { out.push(n.clone()); continue; } // already provider:model + let needle = match nl.as_str() { "chatgpt" => "gpt", other => other }; + if let Some(id) = catalog.iter().find(|id| id.to_lowercase().contains(needle)) { + if !out.contains(id) { out.push(id.clone()); } + } + } + out +} + +/// First http(s) URL or bare `host[/path]` token in the line. +fn find_url(line: &str) -> Option { + for tok in line.split_whitespace() { + let t = tok.trim_matches(|c: char| ",;\"'()[]".contains(c)); + if t.starts_with("http://") || t.starts_with("https://") { return Some(t.to_string()); } + } + for tok in line.split_whitespace() { + let t = tok.trim_matches(|c: char| ",;\"'()[]".contains(c)); + if looks_like_host(t) { return Some(t.to_string()); } + } + None +} + +/// Heuristic: `something.tld` (optionally with a path), not a bare sentence word. +fn looks_like_host(t: &str) -> bool { + let host = t.split('/').next().unwrap_or(t); + if !host.contains('.') || host.starts_with('.') || host.ends_with('.') { return false; } + let tld = host.rsplit('.').next().unwrap_or(""); + tld.len() >= 2 && tld.chars().all(|c| c.is_ascii_alphabetic()) + && host.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') +} + +/// Whole-word membership check (avoids matching "go" inside "google"). +fn word_present(hay_low: &str, word: &str) -> bool { + hay_low.split(|c: char| !c.is_ascii_alphanumeric()).any(|w| w == word) +} + +/// If `chunk_low` starts with (or contains) any keyword, return the ORIGINAL-case +/// remainder after the keyword. +fn after_any<'a>(chunk_low: &str, chunk_orig: &'a str, keys: &[&str]) -> Option<&'a str> { + for k in keys { + if let Some(pos) = chunk_low.find(k) { + let end = pos + k.len(); + let rest = chunk_orig[end..].trim_start_matches([':', ' ', '=', '-']).trim(); + return Some(rest); + } + } + None +} + +fn join_opt(prev: Option, add: &str) -> String { + match prev { + Some(p) if !p.trim().is_empty() => format!("{p}; {add}"), + _ => add.to_string(), + } +} + /// Scan a line for @path tokens, attach each referenced file/dir to context. fn expand_ats(line: &str, s: &mut Session) -> usize { let mut n = 0; @@ -1867,3 +2129,52 @@ 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::()) } } + +#[cfg(test)] +mod nl_tests { + use super::*; + + #[test] + fn fast_parse_pt_target_model_run() { + let (it, conf) = parse_intent_fast("testa https://loja.com com opus"); + assert!(conf); + assert_eq!(it.target.as_deref(), Some("https://loja.com")); + assert!(it.models.contains(&"opus".to_string())); + assert!(it.run); + } + + #[test] + fn fast_parse_clauses_focus_and_scope() { + let (it, _) = parse_intent_fast("scan loja.com, foco em SQLi e IDOR, fora de escopo /admin, roda"); + assert_eq!(it.target.as_deref(), Some("loja.com")); + assert_eq!(it.focus.as_deref(), Some("SQLi e IDOR")); + assert_eq!(it.out_of_scope.as_deref(), Some("/admin")); + assert!(it.run); + } + + #[test] + fn fast_parse_english_and_spanish_run_verbs() { + assert!(parse_intent_fast("run against example.com").0.run); + assert!(parse_intent_fast("prueba example.org enfoque en XSS").0.run); + } + + #[test] + fn ambiguous_freeform_not_confident() { + // No URL, no verb, vague -> defer to model. + let (_it, conf) = parse_intent_fast("da uma olhada naquele site da firma quando puder"); + assert!(!conf); + } + + #[test] + fn host_heuristic_rejects_plain_words() { + assert!(find_url("focar em sqli agora").is_none()); + assert_eq!(find_url("check testphp.vulnweb.com now").as_deref(), Some("testphp.vulnweb.com")); + } + + #[test] + fn model_alias_resolves_to_catalog_id() { + let ids = resolve_model_aliases(&["opus".to_string()]); + assert!(ids.iter().all(|i| i.contains(':'))); + assert!(ids.iter().any(|i| i.to_lowercase().contains("opus"))); + } +}