mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-08-14 21:50:21 +02:00
Merge pull request #42 from JoasASantos/feat/opencode-hermes-providers
feat(3.6.9): OpenCode Zen + Nous Research (Hermes) providers
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h1 align="center">🧠 NeuroSploit v3.6.8</h1>
|
||||
<h1 align="center">🧠 NeuroSploit v3.6.9</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/22624?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-22624" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/22624/daily?language=Python" alt="JoasASantos%2FNeuroSploit | Trendshift" width="250" height="55"/></a>
|
||||
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Version-3.6.8-blue?style=flat-square">
|
||||
<img src="https://img.shields.io/badge/Version-3.6.9-blue?style=flat-square">
|
||||
<img src="https://img.shields.io/badge/Harness-Rust%20%7C%20tokio-e6b673?style=flat-square">
|
||||
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square">
|
||||
<img src="https://img.shields.io/badge/MD%20Agents-435-red?style=flat-square">
|
||||
@@ -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/ \
|
||||
|
||||
+40
-1
@@ -5,7 +5,46 @@
|
||||
**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, 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)
|
||||
|
||||
- **`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.
|
||||
|
||||
+2
-2
@@ -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
|
||||
```
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(", "));
|
||||
|
||||
@@ -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
|
||||
@@ -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") {
|
||||
@@ -370,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");
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -52,6 +52,21 @@ pub fn providers() -> Vec<Provider> {
|
||||
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://<resource>.openai.azure.com), optionally AZURE_OPENAI_API_VERSION
|
||||
// (default 2024-10-21), and use `azure:<your-deployment-name>` 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<std::path::PathBuf> {
|
||||
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
|
||||
|
||||
@@ -610,7 +610,12 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
|
||||
(ag.name.clone(), text, f)
|
||||
}
|
||||
Err(e) => {
|
||||
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![])
|
||||
}
|
||||
}
|
||||
@@ -1883,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
|
||||
@@ -1894,22 +1904,53 @@ 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) => { 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.
|
||||
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::<String>().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 \
|
||||
@@ -1921,7 +1962,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
|
||||
|
||||
@@ -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 <provider:model>` while
|
||||
/// paused — tried first on the next attempt.
|
||||
fallback: Arc<Mutex<Vec<ModelRef>>>,
|
||||
/// 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<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
@@ -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 <provider:model> to switch provider, or re-login and /continue.",
|
||||
short.chars().take(120).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"notify: ⏸ token/quota exhausted ({}). Run is PAUSED — type /continue when your quota renews, or switch with /model <provider:model> then /continue.",
|
||||
short.chars().take(120).collect::<String>()
|
||||
))
|
||||
.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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user