From 3c49a83578744fe82bcd88dfc6d05542503ded1b Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sat, 8 Aug 2026 09:14:21 -0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(3.6.8):=20auth=20resilience=20=E2=80=94?= =?UTF-8?q?=20circuit=20breaker=20pauses=20run=20on=20token=20revocation,?= =?UTF-8?q?=20preserves=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_auth_failure() detector (401, OAuth revoked, session expired, invalid key) - Circuit breaker: 3 consecutive auth failures auto-pause instead of burning 66 agents - Auth-aware park_exhausted(): clear message + fallback provider switch via /continue - No retry burn on auth errors (immediate return like exhaustion) - Recon preserves HTTP probe facts when model auth fails - REPL phase tracking: paused (auth) distinct from paused (quota) - RELEASE.md updated with auth resilience section Co-Authored-By: Claude Opus 4.6 --- RELEASE.md | 28 ++++- neurosploit-rs/app/src/repl.rs | 1 + neurosploit-rs/crates/harness/src/pipeline.rs | 24 +++- neurosploit-rs/crates/harness/src/pool.rs | 104 ++++++++++++++---- 4 files changed, 134 insertions(+), 23 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 6d8f7d2..477cb12 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,7 +5,33 @@ **License:** MIT **Credits:** Joas A Santos & Red Team Leaders -## v3.6.8 — Bugfix: Ollama error handling, empty-evidence validation, single-model warnings +## v3.6.8 — Auth resilience, circuit breaker, Ollama error handling, empty-evidence validation + +### Auth Resilience & Circuit Breaker (NEW) + +- **`is_auth_failure()` detector.** New function recognises OAuth token revocation + (401), session expiry, invalid/revoked API keys, and "not logged in" errors from + subscription CLIs. Distinct from `is_exhaustion()` (quota/rate-limit) — auth + failures are non-recoverable without re-login or provider switch. +- **Circuit breaker (3 consecutive auth failures → auto-pause).** A shared atomic + counter tracks consecutive auth failures across ALL agents. After 3 failures the + pool pauses the run BEFORE burning through the remaining agents on a dead token. + Previously, a revoked OAuth token caused all 66+ agents to silently return 0 + findings with no pause or warning. +- **Auth-aware park: findings preserved, fallback offered.** When auth fails the + run parks with a clear message: + `⏸ authentication failed (...). Run is PAUSED — all findings so far are SAFE.` + The user can `/continue openai:gpt-5.1` (or any provider) to switch and resume. + All `LiveCheckpoint` findings on disk are preserved across the pause. +- **No retry burn on auth failure.** `one()` returns immediately on auth errors + instead of retrying 3 times against a dead token (same as quota exhaustion). +- **Recon preserves probe facts on auth failure.** When model recon fails with an + auth error, the HTTP probe data is still returned and the pipeline continues + with probe-only intelligence instead of silently dropping everything. +- **Phase tracking for auth pauses.** The REPL status line shows `paused (auth)` + (distinct from `paused (quota)`) so the operator knows the root cause at a glance. + +### Bugfixes - **Better Ollama/local provider error messages.** Connection-refused and timeout errors now name the provider, URL, and suggest checking if the server is running. diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index c1a69d1..f3d5841 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -68,6 +68,7 @@ impl RunLive { if self.feed.len() > 200 { self.feed.remove(0); } } if low.contains("token/quota exhausted") || low.contains("run is paused") { self.phase = "paused (quota)".into(); } + else if low.contains("authentication failed") || low.contains("auth failed") || low.contains("circuit breaker") { self.phase = "paused (auth)".into(); } else if low.contains("resumed — retrying") { self.phase = "exploiting".into(); } else if low.starts_with("recon") || low.starts_with("ai-recon") || low.contains("recon round") || low.contains("intensity") || low.starts_with("probe:") { self.phase = "recon".into(); } else if low.contains("selected") && low.contains("agent") { diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 61760b8..431b49d 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -610,7 +610,12 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender { - let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await; + let is_auth = crate::pool::is_auth_failure(&e); + if is_auth { + let _ = txc.send(format!("⚠ exploit {} auth failed — findings so far are SAFE, run is pausing: {e}", ag.name)).await; + } else { + let _ = txc.send(format!("exploit {} failed: {e}", ag.name)).await; + } (ag.name.clone(), format!("ERROR: {e}"), vec![]) } } @@ -1900,7 +1905,16 @@ async fn deep_recon(cfg: &RunConfig, pool: &ModelPool, probe_facts: &str, tx: &S let _ = tx.send(format!("recon: intensity {} — actively enumerating (installing tools as needed)…", intensity)).await; match pool.complete_routed(Task::Recon, "recon", RECON_SYS, &user).await { Ok((m, t)) => { let _ = tx.send(format!("recon round 1 complete via {}", m.label())).await; accum.push_str(&format!("\n\nMODEL RECON (round 1):\n{t}")); } - Err(e) => { let _ = tx.send(format!("recon round 1 failed ({e}) — probe facts only")).await; return accum; } + Err(e) => { + let is_auth = crate::pool::is_auth_failure(&e); + if is_auth { + let _ = tx.send(format!("recon round 1 auth failed ({e}) — continuing with probe facts; run will pause before exploit phase")).await; + accum.push_str(&format!("\n\nMODEL RECON (round 1):\n{e}")); + } else { + let _ = tx.send(format!("recon round 1 failed ({e}) — probe facts only")).await; + } + return accum; + } } // Follow-up expansion rounds — each digs further using what's known so far. @@ -1921,7 +1935,11 @@ async fn deep_recon(cfg: &RunConfig, pool: &ModelPool, probe_facts: &str, tx: &S if novel.len() > 20 { let _ = tx.send(format!("recon round {round} via {} — expanded surface", m.label())).await; accum.push_str(&format!("\n\nMODEL RECON (round {round}):\n{novel}")); } else { let _ = tx.send(format!("recon round {round}: no new surface — recon converged")).await; break; } } - Err(e) => { let _ = tx.send(format!("recon round {round} failed ({e})")).await; break; } + Err(e) => { + let is_auth = crate::pool::is_auth_failure(&e); + let _ = tx.send(format!("recon round {round} {} ({e})", if is_auth { "auth failed" } else { "failed" })).await; + break; + } } } accum diff --git a/neurosploit-rs/crates/harness/src/pool.rs b/neurosploit-rs/crates/harness/src/pool.rs index fc9bc53..49647f5 100644 --- a/neurosploit-rs/crates/harness/src/pool.rs +++ b/neurosploit-rs/crates/harness/src/pool.rs @@ -20,6 +20,24 @@ pub fn is_exhaustion(e: &anyhow::Error) -> bool { .any(|k| s.contains(k)) } +/// Does this error look like an **authentication / authorization failure** +/// (revoked OAuth, expired session, invalid API key) — distinct from transient +/// quota/rate issues? Auth failures are non-recoverable without re-login, so +/// the run should pause immediately and offer fallback providers. +pub fn is_auth_failure(e: &anyhow::Error) -> bool { + let s = format!("{e:#}").to_lowercase(); + [ + "401", "403", "unauthorized", "token has been revoked", + "token revoked", "access token", "oauth", "session expired", + "not authenticated", "not logged in", "please log in", + "please login", "invalid api key", "invalid_api_key", + "api key expired", "authentication failed", "failed to authenticate", + "run /login", + ] + .iter() + .any(|k| s.contains(k)) +} + /// Task type used by the model router to pick the best model for the step. #[derive(Clone, Copy, Debug)] pub enum Task { @@ -64,6 +82,10 @@ pub struct ModelPool { /// Fallback models the user added via `/continue ` while /// paused — tried first on the next attempt. fallback: Arc>>, + /// Circuit breaker: consecutive auth/exhaustion failures across agents. + /// When this exceeds `AUTH_FAIL_THRESHOLD`, the pool auto-pauses instead of + /// burning through the remaining agents on a dead token. + consecutive_auth_fails: Arc, } impl ModelPool { @@ -96,9 +118,20 @@ impl ModelPool { paused: Arc::new(AtomicBool::new(false)), resume: Arc::new(Notify::new()), fallback: Arc::new(Mutex::new(Vec::new())), + consecutive_auth_fails: Arc::new(std::sync::atomic::AtomicUsize::new(0)), } } + /// Reset the consecutive auth-failure counter (called on any successful completion). + fn reset_auth_fails(&self) { + self.consecutive_auth_fails.store(0, std::sync::atomic::Ordering::Relaxed); + } + + /// Increment the consecutive auth-failure counter and return the new count. + fn inc_auth_fails(&self) -> usize { + self.consecutive_auth_fails.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 + } + /// Attach a progress channel so the subscription CLI streams structured /// activity (commands run, files read, tools called) live. pub fn set_progress(&self, tx: tokio::sync::mpsc::Sender) { @@ -146,20 +179,32 @@ impl ModelPool { self.paused.load(Ordering::Relaxed) } - /// Park the run on token/quota exhaustion: keep ALL state, emit a notice, - /// and wait until the user runs `/continue` (or cancels). Returns when the - /// run should retry (pause cleared) or give up (cancelled). - async fn park_exhausted(&self, err: &anyhow::Error) { + /// Consecutive auth/quota failures needed to trip the circuit breaker and + /// auto-pause the run. Low threshold: 3 consecutive failures on the same + /// provider is enough signal that the token is dead. + const AUTH_FAIL_THRESHOLD: usize = 3; + + /// Park the run on token/quota exhaustion or auth failure: keep ALL state, + /// emit a notice, and wait until the user runs `/continue` (or cancels). + /// Returns when the run should retry (pause cleared) or give up (cancelled). + async fn park_exhausted(&self, err: &anyhow::Error, is_auth: bool) { self.paused.store(true, Ordering::Relaxed); if let Some(tx) = self.progress() { let msg = format!("{err:#}"); let short = msg.lines().next().unwrap_or(&msg); - let _ = tx - .send(format!( + let notice = if is_auth { + format!( + "notify: ⏸ authentication failed ({}). Run is PAUSED — all findings so far are SAFE. \ + Fix: /continue to switch provider, or re-login and /continue.", + short.chars().take(120).collect::() + ) + } else { + format!( "notify: ⏸ token/quota exhausted ({}). Run is PAUSED — type /continue when your quota renews, or switch with /model then /continue.", short.chars().take(120).collect::() - )) - .await; + ) + }; + let _ = tx.send(notice).await; } while self.paused.load(Ordering::Relaxed) && !self.is_cancelled() { let notified = self.resume.notified(); @@ -169,8 +214,9 @@ impl ModelPool { } } if !self.is_cancelled() { + self.reset_auth_fails(); // user resumed, reset counter if let Some(tx) = self.progress() { - let _ = tx.send("notify: ▶ resumed — retrying exhausted step.".to_string()).await; + let _ = tx.send("notify: ▶ resumed — retrying with updated credentials/model.".to_string()).await; } } } @@ -212,9 +258,9 @@ impl ModelPool { }; match r { Ok(t) => return Ok(t), - // Don't burn retries on exhaustion — surface it so the caller - // can park and let the user /continue. - Err(e) if is_exhaustion(&e) => return Err(e), + // Don't burn retries on exhaustion or auth failure — surface + // immediately so the caller can park and let the user /continue. + Err(e) if is_exhaustion(&e) || is_auth_failure(&e) => return Err(e), Err(e) => last = e, } } @@ -233,6 +279,20 @@ impl ModelPool { if self.is_cancelled() { return Err(anyhow!("cancelled")); } + // Circuit breaker: if we've seen N consecutive auth failures across + // agents, pause immediately — don't burn another agent on a dead token. + let fail_count = self.consecutive_auth_fails.load(std::sync::atomic::Ordering::Relaxed); + if fail_count >= Self::AUTH_FAIL_THRESHOLD && !self.is_cancelled() { + self.park_exhausted( + &anyhow!("circuit breaker: {} consecutive auth failures — token/session likely dead", fail_count), + true, + ).await; + if self.is_cancelled() { + return Err(anyhow!("cancelled")); + } + // After resume, retry with potentially new fallback models. + continue; + } // User-supplied fallback models (via /continue) are tried first. let mut order = self.route(task); if let Ok(fb) = self.fallback.lock() { @@ -244,25 +304,31 @@ impl ModelPool { } let mut last = anyhow!("no candidate models"); let mut exhausted = false; + let mut auth_failed = false; for m in &order { if self.is_cancelled() { return Err(anyhow!("cancelled")); } match self.one(label, m, system, user).await { - Ok(text) => return Ok((m.clone(), text)), + Ok(text) => { + self.reset_auth_fails(); // success resets circuit breaker + return Ok((m.clone(), text)); + } Err(e) => { - if is_exhaustion(&e) { + if is_auth_failure(&e) { + auth_failed = true; + self.inc_auth_fails(); + } else if is_exhaustion(&e) { exhausted = true; } last = e; } } } - // Every candidate failed. If it was token/quota exhaustion, park the - // run until the user runs /continue, then retry the whole order (now - // including any fallback model they added). Otherwise, give up. - if exhausted && !self.is_cancelled() { - self.park_exhausted(&last).await; + // Every candidate failed. Park the run (keeping all state) so the user + // can fix auth or wait for quota renewal, then /continue. + if (auth_failed || exhausted) && !self.is_cancelled() { + self.park_exhausted(&last, auth_failed).await; continue; } return Err(last); From 1f8ccb6f9e561a590d91245234ce477f98c373fc Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sat, 8 Aug 2026 10:10:43 -0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(3.6.8):=20recon=20time=20budget=20?= =?UTF-8?q?=E2=80=94=205min=20cap=20prevents=20recon=20from=20eating=20ent?= =?UTF-8?q?ire=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add RECON_TOTAL_BUDGET_SECS (300s) total wall-clock cap across all rounds - Per-round budget directive in prompt: 30-50 commands max, stop early if enough intel - Elapsed time check between rounds: skip remaining if budget exhausted - Remaining time communicated to follow-up rounds for self-pacing - RELEASE.md updated with recon budget section Previously: subscription CLI recon ran 150+ commands over 15 min, exploitation never started. Now: recon caps at 5 min total, then proceeds to agent exploitation. Co-Authored-By: Claude Opus 4.6 --- RELEASE.md | 15 ++++++++- neurosploit-rs/crates/harness/src/pipeline.rs | 33 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 477cb12..1a069a2 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,7 +5,20 @@ **License:** MIT **Credits:** Joas A Santos & Red Team Leaders -## v3.6.8 — Auth resilience, circuit breaker, Ollama error handling, empty-evidence validation +## v3.6.8 — Auth resilience, circuit breaker, recon budget, Ollama error handling, empty-evidence validation + +### Recon Time Budget (NEW) + +- **5-minute total recon budget.** Recon phase is now time-boxed to 300 seconds + across ALL rounds. Previously, a single recon round could run 150+ commands + over 15 minutes via subscription CLI, leaving no time for exploitation. +- **Per-round budget directive.** Each recon round receives a prompt instruction + with its share of the time budget (e.g. "~100 seconds for this round") and a + command count guideline (30-50 commands max). The model is instructed to + prioritise high-signal actions and stop early when enough intel is gathered. +- **Elapsed time check between rounds.** Before starting each follow-up round, + the pipeline checks elapsed time. If the budget is exhausted, recon stops + immediately and proceeds to exploitation with the intelligence gathered so far. ### Auth Resilience & Circuit Breaker (NEW) diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 431b49d..c5621bb 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -1888,6 +1888,11 @@ fn recon_intensity_directive(level: usize) -> String { (8) TLS/headers/cookies. Report counts (how many subdomains/urls/params/endpoints you actually found).\n\n") } +/// Max wall-clock seconds for the ENTIRE recon phase (all rounds combined). +/// This prevents recon from eating the whole run — exploitation must start. +/// Per-round cap = total / (rounds + 1) so later rounds get equal time. +const RECON_TOTAL_BUDGET_SECS: u64 = 300; // 5 minutes total + /// Intense, multi-round recon: an initial deep pass, then follow-up rounds that /// EXPAND the surface (chase discovered subdomains/endpoints/params, install /// tools, dig where the previous round found signal). Returns the merged recon @@ -1899,10 +1904,23 @@ async fn deep_recon(cfg: &RunConfig, pool: &ModelPool, probe_facts: &str, tx: &S let intensity_dir = recon_intensity_directive(intensity); let dir = operator_directives(cfg); let mut accum = format!("OBSERVED HTTP PROBE:\n{probe_facts}"); + let recon_start = std::time::Instant::now(); + let total_rounds = 1 + extra_rounds; + + // Time-budget directive: subscription CLIs run commands autonomously, so they + // need an explicit cap to avoid running 150+ commands in a single round. + let budget_dir = format!( + "TIME BUDGET: you have ~{budget_secs} seconds for THIS recon round. Be EFFICIENT: \ + prioritise high-signal actions (JS analysis, API mapping, SQLi/auth probes) over \ + exhaustive crawling. AIM for 30-50 commands max per round — enough to map the surface, \ + not so many that exploitation never starts. STOP EARLY if you have enough intel to \ + select agents. When done, EMIT YOUR RESULTS IMMEDIATELY — do not start another pass.\n\n", + budget_secs = RECON_TOTAL_BUDGET_SECS / total_rounds as u64, + ); // Initial deep pass. - let user = format!("{dir}{intensity_dir}{doctrine}OBSERVED HTTP PROBE (build on these, verify, go deeper):\n{probe_facts}\n\nTarget: {}", cfg.target); - let _ = tx.send(format!("recon: intensity {} — actively enumerating (installing tools as needed)…", intensity)).await; + let user = format!("{dir}{budget_dir}{intensity_dir}{doctrine}OBSERVED HTTP PROBE (build on these, verify, go deeper):\n{probe_facts}\n\nTarget: {}", cfg.target); + let _ = tx.send(format!("recon: intensity {} — actively enumerating (budget {}s total, {} round(s))…", intensity, RECON_TOTAL_BUDGET_SECS, total_rounds)).await; match pool.complete_routed(Task::Recon, "recon", RECON_SYS, &user).await { Ok((m, t)) => { let _ = tx.send(format!("recon round 1 complete via {}", m.label())).await; accum.push_str(&format!("\n\nMODEL RECON (round 1):\n{t}")); } Err(e) => { @@ -1920,10 +1938,19 @@ async fn deep_recon(cfg: &RunConfig, pool: &ModelPool, probe_facts: &str, tx: &S // Follow-up expansion rounds — each digs further using what's known so far. for r in 0..extra_rounds { if pool.stop_exploiting() { break; } + // Time budget check: if recon has already consumed the total budget, stop + // and proceed to exploitation with whatever intelligence we gathered. + let elapsed = recon_start.elapsed().as_secs(); + if elapsed >= RECON_TOTAL_BUDGET_SECS { + let _ = tx.send(format!("recon: time budget exhausted ({elapsed}s/{RECON_TOTAL_BUDGET_SECS}s) — proceeding to exploitation with current intel")).await; + break; + } + let remaining = RECON_TOTAL_BUDGET_SECS - elapsed; let round = r + 2; let known: String = accum.chars().rev().take(3000).collect::().chars().rev().collect(); let follow = format!( - "{dir}{intensity_dir}{doctrine}CONTINUE the recon — this is round {round}. Here is what recon has found so far:\n{known}\n\n\ + "{dir}TIME BUDGET: you have ~{remaining} seconds remaining for recon. Be CONCISE — focus only on the highest-value leads.\n\n\ + {intensity_dir}{doctrine}CONTINUE the recon — this is round {round}. Here is what recon has found so far:\n{known}\n\n\ Now EXPAND: pick the most promising leads and go deeper — resolve & probe any NEW subdomains/hosts, crawl \ and harvest URLs for endpoints not yet mapped, run content/parameter discovery where you saw interesting \ paths, fingerprint exact versions of anything unclear, and enumerate the API/GraphQL further. Install any \ From 69c5e3ddb92f4d23925fe1f137d1709fa36cb3a5 Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Tue, 11 Aug 2026 23:47:12 -0300 Subject: [PATCH 3/3] feat(3.6.9): OpenCode Zen + Nous Research (Hermes) providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new model providers, both usable via API key or --subscription (local CLI login, no key): - opencode: OpenCode Zen gateway (OPENCODE_API_KEY, opencode.ai/zen/v1). Subscription mode drives the `opencode` CLI (`opencode run --auto`). Supports the Playwright MCP (--mcp): our .mcp.json is converted to OpenCode's own config schema and injected via OPENCODE_CONFIG. - nous: Nous Research / Hermes models (NOUS_API_KEY, inference-api.nousresearch.com/v1). Subscription mode drives the `hermes` CLI (NousResearch/hermes-agent) on the user's Nous Portal OAuth login (`hermes setup --portal`), via `hermes chat -q`. No CLI-level MCP hook — falls back to Hermes's own built-in toolsets (web/terminal/computer-use). Both wired into cli_binary_for, installed_cli_backends, cli_login_status (prompt passed as argv, not stdin — neither CLI reads stdin for this). Bump version 3.6.8 -> 3.6.9 across Cargo.toml, README, TUTORIAL, setup.sh, install.ps1, and in-binary version strings. README/.env.example updated with the new provider rows and subscription-login table. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HHFAVCHMvRkTy9Wgw7SayG --- .env.example | 10 ++ README.md | 14 ++- TUTORIAL.md | 4 +- install.ps1 | 4 +- neurosploit-rs/Cargo.lock | 4 +- neurosploit-rs/Cargo.toml | 2 +- neurosploit-rs/app/src/main.rs | 12 +- neurosploit-rs/app/src/repl.rs | 4 +- neurosploit-rs/app/src/tui.rs | 2 +- neurosploit-rs/crates/harness/src/models.rs | 121 ++++++++++++++++++-- setup.sh | 4 +- 11 files changed, 152 insertions(+), 29 deletions(-) diff --git a/.env.example b/.env.example index fd8774e..14688f2 100755 --- a/.env.example +++ b/.env.example @@ -51,6 +51,16 @@ TOGETHER_API_KEY= # openrouter: https://openrouter.ai/keys OPENROUTER_API_KEY= +# opencode: https://opencode.ai/auth (OpenCode Zen gateway) +# Or skip the key entirely and use --subscription with the +# `opencode` CLI logged into your own Zen/plan account. +OPENCODE_API_KEY= + +# nous: Nous Portal (https://portal.nousresearch.com) — Hermes models. +# Or skip the key entirely and use --subscription with the +# `hermes` CLI (`hermes setup --portal` for OAuth login). +NOUS_API_KEY= + # ollama: local, no key needed. Override the endpoint if not default: #OLLAMA_BASE_URL=http://localhost:11434/v1 diff --git a/README.md b/README.md index 5d7b419..4bdf6c5 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

🧠 NeuroSploit v3.6.8

+

🧠 NeuroSploit v3.6.9

JoasASantos%2FNeuroSploit | Trendshift @@ -12,7 +12,7 @@

- + @@ -433,6 +433,8 @@ export GROQ_API_KEY=... # groq:* export TOGETHER_API_KEY=... # together:* export MOONSHOT_API_KEY=... # moonshot:* (Kimi K3/K2) export OPENROUTER_API_KEY=... # openrouter:* +export OPENCODE_API_KEY=... # opencode:* (OpenCode Zen gateway) +export NOUS_API_KEY=... # nous:* (Nous Portal — Hermes) # ollama / llamacpp need no key (local) # then run via API (note: NO --subscription) @@ -462,6 +464,8 @@ Or put the keys in a `.env` and source it (`cp .env.example .env`; edit; `set -a | `together:` | `TOGETHER_API_KEY` | api.together.xyz | | `moonshot:` | `MOONSHOT_API_KEY` | api.moonshot.ai | | `openrouter:` | `OPENROUTER_API_KEY` | openrouter.ai | +| `opencode:` | `OPENCODE_API_KEY` | opencode.ai/zen (OpenCode Zen gateway) | +| `nous:` | `NOUS_API_KEY` | inference-api.nousresearch.com (Hermes 4) | | `ollama:` | _(none)_ | localhost:11434 | | `llamacpp:` | _(none)_ | localhost:8080 | @@ -484,6 +488,12 @@ install and log into one of the CLIs first: | `openai:` | `codex` | `codex` login | | `gemini:` | `gemini` | `gemini` login | | `xai:` | `grok` | `grok` login | +| `opencode:` | `opencode` | `opencode auth login` (or `/connect` in the TUI) — Zen/plan account | +| `nous:` | `hermes` | `hermes setup --portal` — Nous Portal OAuth | + +`opencode:` also gets the Playwright MCP (`--mcp`) like anthropic/openai do. +`nous:` relies on Hermes's own built-in toolsets (web/terminal/computer-use) +instead — it has no CLI-level MCP hook. ```bash ./target/release/neurosploit run http://testphp.vulnweb.com/ \ diff --git a/TUTORIAL.md b/TUTORIAL.md index ef36222..37dad79 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -1,4 +1,4 @@ -# NeuroSploit — Tutorial & User Guide (v3.6.8) +# NeuroSploit — Tutorial & User Guide (v3.6.9) A complete, hands-on guide to installing, configuring and running NeuroSploit — the autonomous, multi-model penetration-testing harness. @@ -98,7 +98,7 @@ Agents **degrade gracefully**: if `rustscan` is absent they use `nmap`; if neith ### Verify ```bash -neurosploit --version # neurosploit 3.6.8 +neurosploit --version # neurosploit 3.6.9 neurosploit agents # {"vulns":241,...,"ai":30,...,"total":430} neurosploit models # all providers & models ``` diff --git a/install.ps1 b/install.ps1 index 4342dcb..98da84b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -14,7 +14,7 @@ function Ok ($m) { Write-Host " + $m" -ForegroundColor Green } function Warn($m){ Write-Host " ! $m" -ForegroundColor Yellow } Write-Host "" -Write-Host " NeuroSploit installer (Windows) — v3.6.8" -ForegroundColor Cyan +Write-Host " NeuroSploit installer (Windows) — v3.6.9" -ForegroundColor Cyan # arch → asset arch (only x64 prebuilt today; arm64 falls back to source) $rawArch = $env:PROCESSOR_ARCHITECTURE @@ -29,7 +29,7 @@ $ref = $env:NEUROSPLOIT_REF if (-not $ref) { try { $ref = (Invoke-RestMethod "https://api.github.com/repos/$slug/releases/latest").tag_name } catch { } } -if (-not $ref) { $ref = "v3.6.8" } +if (-not $ref) { $ref = "v3.6.9" } Say "Release: $ref" New-Item -ItemType Directory -Force -Path $dir | Out-Null diff --git a/neurosploit-rs/Cargo.lock b/neurosploit-rs/Cargo.lock index 7f000d8..0a25272 100644 --- a/neurosploit-rs/Cargo.lock +++ b/neurosploit-rs/Cargo.lock @@ -871,7 +871,7 @@ dependencies = [ [[package]] name = "neurosploit" -version = "3.6.8" +version = "3.6.9" dependencies = [ "anyhow", "clap", @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "neurosploit-harness" -version = "3.6.8" +version = "3.6.9" dependencies = [ "anyhow", "futures", diff --git a/neurosploit-rs/Cargo.toml b/neurosploit-rs/Cargo.toml index fcb863b..0005c05 100644 --- a/neurosploit-rs/Cargo.toml +++ b/neurosploit-rs/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/harness", "app"] resolver = "2" [workspace.package] -version = "3.6.8" +version = "3.6.9" edition = "2021" license = "MIT" repository = "https://github.com/JoasASantos/NeuroSploit" diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index 6bd45ce..fe3a493 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -1,4 +1,4 @@ -//! NeuroSploit v3.6.8 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`). +//! NeuroSploit v3.6.9 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`). mod repl; mod tui; @@ -11,9 +11,9 @@ use std::path::{Path, PathBuf}; #[command( name = "neurosploit", version, - about = "NeuroSploit v3.6.8 — multi-model autonomous pentest harness", - long_about = "NeuroSploit v3.6.8 — a Rust multi-model harness that drives a pool of LLMs \ -(API key or local subscription: Claude/Codex/Gemini/Grok) to autonomously test a target. \ + about = "NeuroSploit v3.6.9 — multi-model autonomous pentest harness", + long_about = "NeuroSploit v3.6.9 — a Rust multi-model harness that drives a pool of LLMs \ +(API key or local subscription: Claude/Codex/Gemini/Grok/OpenCode/Hermes) to autonomously test a target. \ After recon it INTELLIGENTLY selects only the agents matching the discovered surface, runs \ them in parallel, then validates every finding by cross-model voting before reporting.\n\n\ Run with NO arguments for an interactive wizard.\n\n\ @@ -54,7 +54,7 @@ enum Cmd { recon: usize, #[arg(long)] offline: bool, - /// Use local agentic CLI subscription (Claude/Codex/Gemini/Grok login). + /// Use local agentic CLI subscription (Claude/Codex/Gemini/Grok/OpenCode/Hermes login). #[arg(long)] subscription: bool, /// Enable Playwright MCP (auto-installed if missing; backends that don't @@ -765,7 +765,7 @@ pub(crate) fn spawn_engagement(base: &Path, mut cfg: RunConfig, mcp: bool, mode: println!(" │ ua : {ua}"); write_status(&workdir, "running", &format!("\"target\":{:?}", cfg.target)); - println!(" ┌─ NeuroSploit v3.6.8 · by Joas A Santos & Red Team Leaders"); + println!(" ┌─ NeuroSploit v3.6.9 · by Joas A Santos & Red Team Leaders"); println!(" │ run id : {run_id}"); println!(" │ target : {}", cfg.target); println!(" │ models : {}", cfg.models.join(", ")); diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index f3d5841..1b1272e 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -1,4 +1,4 @@ -//! NeuroSploit v3.6.8 — interactive session (Claude-Code / Codex / Cursor-CLI style). +//! NeuroSploit v3.6.9 — interactive session (Claude-Code / Codex / Cursor-CLI style). //! //! Launched when `neurosploit` runs with no subcommand. A persistent REPL with //! real line editing (arrow-key history recall, Ctrl-A/E/K, paste), model @@ -371,7 +371,7 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { let backends = harness::installed_cli_backends(); println!("\x1b[1m"); println!(" ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗"); - println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.8"); + println!(" ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit v3.6.9"); println!(" ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ interactive harness"); println!(" ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos"); println!(" ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders"); diff --git a/neurosploit-rs/app/src/tui.rs b/neurosploit-rs/app/src/tui.rs index 0d615d1..68fe71d 100644 --- a/neurosploit-rs/app/src/tui.rs +++ b/neurosploit-rs/app/src/tui.rs @@ -1,4 +1,4 @@ -//! NeuroSploit v3.6.8 — TUI "Mission Control" mode. +//! NeuroSploit v3.6.9 — 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: diff --git a/neurosploit-rs/crates/harness/src/models.rs b/neurosploit-rs/crates/harness/src/models.rs index ef7dc04..e4bb108 100644 --- a/neurosploit-rs/crates/harness/src/models.rs +++ b/neurosploit-rs/crates/harness/src/models.rs @@ -52,6 +52,21 @@ pub fn providers() -> Vec { models: vec!["gpt-4o", "claude-3-7-sonnet", "gemini/gemini-2.5-pro"] }, Provider { key: "openrouter", label: "OpenRouter", base_url: "https://openrouter.ai/api/v1", env_key: "OPENROUTER_API_KEY", kind: "api", models: vec!["anthropic/claude-opus-4-8", "qwen/qwen-2.5-coder-32b-instruct", "deepseek/deepseek-r1", "meta-llama/llama-3.3-70b-instruct"] }, + // OpenCode Zen — the curated OpenAI-compatible gateway behind the + // `opencode` CLI (https://opencode.ai/zen). Works two ways, like + // anthropic/openai/xai/gemini above: as a plain API-key provider here, + // or (with --subscription) driven through the locally-installed + // `opencode` agentic CLI on the user's own Zen/plan login — no key + // needed in that mode. `kind: "cli"` reflects the latter. + Provider { key: "opencode", label: "OpenCode Zen", base_url: "https://opencode.ai/zen/v1", env_key: "OPENCODE_API_KEY", kind: "cli", + models: vec!["claude-opus-5", "claude-sonnet-5", "gpt-5.6-sol", "gpt-5.5", "gemini-3-pro", "grok-4.5", "deepseek-v4-pro", "qwen3.7-max", "kimi-k3"] }, + // Nous Research — Hermes models via the Nous Portal. As an API-key + // provider here (OpenAI-compatible `inference-api.nousresearch.com`), + // or (with --subscription) driven through the `hermes` CLI + // (NousResearch/hermes-agent) on the user's OAuth Portal login + // (`hermes setup --portal`) — 300+ routed frontier models, no key. + Provider { key: "nous", label: "Nous Research (Hermes)", base_url: "https://inference-api.nousresearch.com/v1", env_key: "NOUS_API_KEY", kind: "cli", + models: vec!["Hermes-4-405B", "Hermes-4-70B", "DeepHermes-3-Mistral-24B-Preview"] }, // Azure OpenAI (OpenAI-compatible). Set AZURE_OPENAI_ENDPOINT (e.g. // https://.openai.azure.com), optionally AZURE_OPENAI_API_VERSION // (default 2024-10-21), and use `azure:` as the model. @@ -226,6 +241,11 @@ impl ChatClient { } let mut cmd = Command::new(bin); + // Most agentic CLIs here take the prompt on stdin; opencode and hermes + // take it as a trailing positional argument instead — track which so we + // don't also pipe it into stdin below (that would just hang the child + // waiting on a request it already got as an argv value). + let mut prompt_via_stdin = true; match bin { // Codex non-interactive exec (uses the ChatGPT/Codex login), prompt on stdin. "codex" => { @@ -250,13 +270,46 @@ impl ChatClient { "grok" => { cmd.arg("--model").arg(model); } + // OpenCode CLI (`opencode run`) — non-interactive one-shot, prompt + // as a positional arg, not stdin. `--auto` auto-approves anything + // not explicitly denied (our equivalent of --dangerously-skip-permissions). + // MCP (Playwright) is injected via a generated opencode.json pointed + // at through OPENCODE_CONFIG rather than a CLI flag (opencode has none). + "opencode" => { + prompt_via_stdin = false; + cmd.arg("run").arg("--model").arg(model).arg("--auto"); + if let Some(mcp) = mcp_config { + match write_opencode_mcp_config(mcp) { + Ok(cfg) => { cmd.env("OPENCODE_CONFIG", cfg); } + Err(e) => eprintln!(" [!] opencode MCP config failed: {e}"), + } + } + cmd.arg(&prompt); + } + // Hermes Agent CLI (NousResearch/hermes-agent) — single-query mode. + // `-q` is the prompt-supplying flag (not a stdin read); `--provider + // nous` pins the Nous Portal OAuth login; `-Q` quiets banner/spinner + // for programmatic use; `--yolo` bypasses dangerous-command prompts. + // No CLI-level MCP hook — Hermes falls back to its own built-in + // toolsets (web/terminal/computer-use) rather than our Playwright MCP. + "hermes" => { + prompt_via_stdin = false; + cmd.arg("chat").arg("-m").arg(model).arg("--provider").arg("nous") + .arg("-Q").arg("--yolo").arg("-q").arg(&prompt); + } _ => {} } cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); let mut child = cmd.spawn().map_err(|e| anyhow!("spawn {} failed: {}", bin, e))?; - if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(prompt.as_bytes()).await?; - // Drop closes stdin so the CLI processes the prompt and exits. + if prompt_via_stdin { + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(prompt.as_bytes()).await?; + // Drop closes stdin so the CLI processes the prompt and exits. + } + } else { + // Prompt went in as an argv value; close stdin immediately so + // nothing lingers waiting on it (opencode/hermes never read it). + drop(child.stdin.take()); } // Cap a single agentic CLI turn so a stuck tool-loop can't hang the run. let out = match tokio::time::timeout(Duration::from_secs(600), child.wait_with_output()).await { @@ -577,6 +630,8 @@ pub fn cli_binary_for(provider: &str) -> Option<&'static str> { "openai" => Some("codex"), "xai" => Some("grok"), "gemini" => Some("gemini"), + "opencode" => Some("opencode"), + "nous" => Some("hermes"), _ => None, } } @@ -590,7 +645,7 @@ pub fn binary_in_path(name: &str) -> bool { /// Which subscription CLI backends are installed locally. pub fn installed_cli_backends() -> Vec<&'static str> { - ["claude", "codex", "grok", "gemini"].into_iter().filter(|b| binary_in_path(b)).collect() + ["claude", "codex", "grok", "gemini", "opencode", "hermes"].into_iter().filter(|b| binary_in_path(b)).collect() } /// Login state of a subscription CLI backend. @@ -610,15 +665,23 @@ pub async fn cli_login_status(provider: &str) -> LoginStatus { let Some(bin) = cli_binary_for(provider) else { return LoginStatus::NotInstalled }; if !binary_in_path(bin) { return LoginStatus::NotInstalled; } let mut cmd = Command::new(bin); + // opencode/hermes take the probe prompt as an argv value, not stdin. + let prompt_via_stdin = !matches!(bin, "opencode" | "hermes"); match bin { "claude" => { cmd.arg("-p").arg("--output-format").arg("text").arg("--dangerously-skip-permissions"); } "codex" => { cmd.arg("exec").arg("--dangerously-bypass-approvals-and-sandbox").arg("-"); } + "opencode" => { cmd.arg("run").arg("--auto").arg("Reply with exactly: OK"); } + "hermes" => { cmd.arg("chat").arg("--provider").arg("nous").arg("-Q").arg("--yolo").arg("-q").arg("Reply with exactly: OK"); } _ => { cmd.arg("-p"); } // grok / gemini: prompt on stdin } cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); let mut child = match cmd.spawn() { Ok(c) => c, Err(_) => return LoginStatus::Unknown }; - if let Some(mut stdin) = child.stdin.take() { - let _ = stdin.write_all(b"Reply with exactly: OK").await; + if prompt_via_stdin { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(b"Reply with exactly: OK").await; + } + } else { + drop(child.stdin.take()); } let out = match tokio::time::timeout(Duration::from_secs(45), child.wait_with_output()).await { Ok(Ok(o)) => o, @@ -642,10 +705,50 @@ pub async fn cli_login_status(provider: &str) -> LoginStatus { } /// Does this provider's agentic CLI accept a Playwright MCP config? -/// Claude Code and Codex do; Gemini/Grok CLIs don't take an MCP-config flag, so -/// they fall back to their own built-in tools. +/// Claude Code, Codex, and OpenCode do (OpenCode via a generated +/// `opencode.json` + `OPENCODE_CONFIG`, see `write_opencode_mcp_config`). +/// Gemini/Grok/Hermes have no CLI-level MCP hook, so they fall back to their +/// own built-in tools (Hermes ships web/terminal/computer-use natively). pub fn mcp_supported(provider: &str) -> bool { - matches!(provider, "anthropic" | "openai") + matches!(provider, "anthropic" | "openai" | "opencode") +} + +/// Convert our `.mcp.json` (`{"mcpServers": {name: {command, args}}}`) into +/// OpenCode's own config schema (`{"mcp": {name: {"type":"local","command": +/// [command, ...args], "enabled": true}}}`) and write it next to the source +/// file. OpenCode has no `--mcp-config` flag; it's pointed at a config file +/// via the `OPENCODE_CONFIG` env var instead (set by the `opencode` arm of +/// `chat_cli`), so this doesn't touch the user's own `opencode.json`. +fn write_opencode_mcp_config(mcp_json_path: &str) -> Result { + let txt = std::fs::read_to_string(mcp_json_path) + .map_err(|e| anyhow!("read {mcp_json_path}: {e}"))?; + let v: serde_json::Value = serde_json::from_str(&txt) + .map_err(|e| anyhow!("parse {mcp_json_path}: {e}"))?; + let servers = v.get("mcpServers").cloned().unwrap_or(v); + let mut mcp = serde_json::Map::new(); + if let Some(obj) = servers.as_object() { + for (name, s) in obj { + let command = s.get("command").and_then(|c| c.as_str()).unwrap_or("").to_string(); + if command.is_empty() { continue; } + let mut argv = vec![serde_json::Value::String(command)]; + if let Some(args) = s.get("args").and_then(|a| a.as_array()) { + argv.extend(args.iter().cloned()); + } + mcp.insert(name.clone(), serde_json::json!({ + "type": "local", + "command": argv, + "enabled": true + })); + } + } + let cfg = serde_json::json!({ + "$schema": "https://opencode.ai/config.json", + "mcp": mcp + }); + let path = std::path::Path::new(mcp_json_path).with_file_name("opencode.json"); + std::fs::write(&path, serde_json::to_string_pretty(&cfg).unwrap_or_default()) + .map_err(|e| anyhow!("write {}: {e}", path.display()))?; + Ok(path) } /// Best-effort ensure the Playwright MCP server is available locally. Requires diff --git a/setup.sh b/setup.sh index 2df24e3..e61f3c2 100755 --- a/setup.sh +++ b/setup.sh @@ -28,7 +28,7 @@ cat <<'BANNER' ███╗ ██╗███████╗██╗ ██╗██████╗ ██████╗ ████╗ ██║██╔════╝██║ ██║██╔══██╗██╔═══██╗ NeuroSploit installer - ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ v3.6.8 — Rust harness + ██╔██╗ ██║█████╗ ██║ ██║██████╔╝██║ ██║ v3.6.9 — Rust harness ██║╚██╗██║██╔══╝ ██║ ██║██╔══██╗██║ ██║ by Joas A Santos ██║ ╚████║███████╗╚██████╔╝██║ ██║╚██████╔╝ & Red Team Leaders ╚═╝ ╚═══╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ @@ -63,7 +63,7 @@ if [ -z "$REF" ]; then REF="$(dl "https://api.github.com/repos/${REPO_SLUG}/releases/latest" /dev/stdout 2>/dev/null \ | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name" *: *"([^"]+)".*/\1/' || true)" fi -[ -z "$REF" ] && REF="v3.6.8" +[ -z "$REF" ] && REF="v3.6.9" say "Release: $REF" installed=0