From e4f6b42f37ddc8b42d3cd849cfd66236c4920f7a Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Sun, 18 Oct 2026 22:28:26 -0300 Subject: [PATCH] feat(harness): add MCP hot-reload, reference prompts, and agent autonomy levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mcp_hot_reload.rs: watches mcp.servers.json for mid-run changes, rewrites .mcp.json so next agent spawn picks up new MCP servers (e.g. Playwright) without restarting the engagement. Includes add_server/remove_server helpers for REPL integration. - reference_prompts: new RunConfig field accepting paths to .md files (e.g. pentest methodology guides) injected into every agent prompt as supplementary testing material (capped at 8000 chars each). - autonomy levels: "cautious", "standard", "aggressive", "full" โ€” shape how aggressively agents explore. Higher levels encourage creative lateral testing, tool installs, WAF bypasses, zero-day hunting, and exhaustive business logic exploration. --- neurosploit-rs/Cargo.lock | 4 +- neurosploit-rs/crates/harness/src/lib.rs | 2 + .../crates/harness/src/mcp_hot_reload.rs | 203 ++++++++++++++++++ neurosploit-rs/crates/harness/src/pipeline.rs | 71 ++++++ neurosploit-rs/crates/harness/src/types.rs | 17 ++ 5 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 neurosploit-rs/crates/harness/src/mcp_hot_reload.rs diff --git a/neurosploit-rs/Cargo.lock b/neurosploit-rs/Cargo.lock index 5f3c733..1a620bb 100644 --- a/neurosploit-rs/Cargo.lock +++ b/neurosploit-rs/Cargo.lock @@ -940,7 +940,7 @@ dependencies = [ [[package]] name = "neurosploit" -version = "4.3.3" +version = "4.4.0" dependencies = [ "anyhow", "clap", @@ -957,7 +957,7 @@ dependencies = [ [[package]] name = "neurosploit-harness" -version = "4.3.3" +version = "4.4.0" dependencies = [ "anyhow", "base64", diff --git a/neurosploit-rs/crates/harness/src/lib.rs b/neurosploit-rs/crates/harness/src/lib.rs index c399ff3..53c8b76 100644 --- a/neurosploit-rs/crates/harness/src/lib.rs +++ b/neurosploit-rs/crates/harness/src/lib.rs @@ -31,6 +31,7 @@ pub mod integrity; pub mod internal; pub mod json_extract; pub mod knowledge_graph; +pub mod mcp_hot_reload; pub mod memory; pub mod policy; pub mod poc; @@ -64,6 +65,7 @@ pub mod waf; pub mod wire; pub use agents::{Agent, Library}; +pub use mcp_hot_reload::{McpReloader, add_server as mcp_add_server, remove_server as mcp_remove_server}; pub use models::{ cli_binary_for, ensure_playwright_mcp, installed_cli_backends, mcp_supported, provider_for, providers, write_mcp_config, ChatClient, ModelRef, Provider, diff --git a/neurosploit-rs/crates/harness/src/mcp_hot_reload.rs b/neurosploit-rs/crates/harness/src/mcp_hot_reload.rs new file mode 100644 index 0000000..4d93f62 --- /dev/null +++ b/neurosploit-rs/crates/harness/src/mcp_hot_reload.rs @@ -0,0 +1,203 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Watches `mcp.servers.json` for changes and rewrites `.mcp.json` so the +/// next agent spawn picks up newly added (or removed) MCP servers mid-run. +/// +/// Each agent gets a fresh CLI subprocess that reads `.mcp.json` from disk, +/// so rewriting the file is enough โ€” no in-process state needs updating. +pub struct McpReloader { + config_dir: PathBuf, + watch_path: PathBuf, + last_mtime: AtomicU64, + generation: Arc, +} + +impl McpReloader { + pub fn new(config_dir: PathBuf, watch_path: PathBuf) -> Self { + let mtime = file_mtime(&watch_path).unwrap_or(0); + Self { + config_dir, + watch_path, + last_mtime: AtomicU64::new(mtime), + generation: Arc::new(AtomicU64::new(0)), + } + } + + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Relaxed) + } + + /// Spawn a background polling task that checks `mcp.servers.json` every + /// 3 seconds. When the file changes (or appears for the first time), + /// `.mcp.json` is rewritten so subsequent agents pick up the new servers. + pub fn spawn( + self: Arc, + progress: Option>, + cancel: Arc, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(3)); + loop { + interval.tick().await; + if cancel.load(Ordering::Relaxed) { + break; + } + let current = file_mtime(&self.watch_path).unwrap_or(0); + let prev = self.last_mtime.load(Ordering::Relaxed); + if current != prev && current != 0 { + self.last_mtime.store(current, Ordering::Relaxed); + match self.reload() { + Ok(servers) => { + let gen = self.generation.fetch_add(1, Ordering::Relaxed) + 1; + if let Some(tx) = &progress { + let _ = tx.send(format!( + "notify: ๐Ÿ”Œ MCP hot-reload (gen {gen}): reloaded {servers} server(s) from {}", + self.watch_path.display() + )).await; + } + } + Err(e) => { + if let Some(tx) = &progress { + let _ = tx.send(format!( + "notify: โš  MCP hot-reload failed: {e}" + )).await; + } + } + } + } + } + }) + } + + /// Rewrite `.mcp.json` with current contents of `mcp.servers.json`. + /// Returns the number of MCP servers in the resulting config. + fn reload(&self) -> anyhow::Result { + let extra = if self.watch_path.exists() { + Some(self.watch_path.as_path()) + } else { + None + }; + crate::models::write_mcp_config(&self.config_dir, extra)?; + let count = server_count(&self.config_dir.join(".mcp.json")); + Ok(count) + } +} + +fn file_mtime(path: &Path) -> Option { + std::fs::metadata(path) + .ok()? + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) +} + +fn server_count(mcp_json: &Path) -> usize { + let Ok(txt) = std::fs::read_to_string(mcp_json) else { return 0 }; + let Ok(v) = serde_json::from_str::(&txt) else { return 0 }; + v.get("mcpServers") + .and_then(|s| s.as_object()) + .map(|o| o.len()) + .unwrap_or(0) +} + +/// Convenience: add a single MCP server to `mcp.servers.json` at runtime +/// (e.g. from a REPL command). Creates the file if it doesn't exist. +pub fn add_server( + servers_path: &Path, + name: &str, + command: &str, + args: &[&str], +) -> anyhow::Result<()> { + let mut obj = if servers_path.exists() { + let txt = std::fs::read_to_string(servers_path)?; + serde_json::from_str::(&txt) + .unwrap_or_else(|_| serde_json::json!({})) + } else { + serde_json::json!({}) + }; + + let servers = obj + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcp.servers.json is not a JSON object"))?; + + servers.insert( + name.to_string(), + serde_json::json!({ "command": command, "args": args }), + ); + + if let Some(parent) = servers_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(servers_path, serde_json::to_string_pretty(&obj)?)?; + Ok(()) +} + +/// Remove a server from `mcp.servers.json`. +pub fn remove_server(servers_path: &Path, name: &str) -> anyhow::Result { + if !servers_path.exists() { + return Ok(false); + } + let txt = std::fs::read_to_string(servers_path)?; + let mut obj: serde_json::Value = serde_json::from_str(&txt)?; + let removed = obj + .as_object_mut() + .map(|o| o.remove(name).is_some()) + .unwrap_or(false); + if removed { + std::fs::write(servers_path, serde_json::to_string_pretty(&obj)?)?; + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_dir(suffix: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!( + "ns_mcp_test_{}_{suffix}_{:x}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )); + std::fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn add_and_remove_server() { + let dir = test_dir("add_rm"); + let path = dir.join("mcp.servers.json"); + add_server(&path, "test-server", "node", &["server.js"]).unwrap(); + let txt = std::fs::read_to_string(&path).unwrap(); + assert!(txt.contains("test-server")); + assert!(txt.contains("node")); + assert!(remove_server(&path, "test-server").unwrap()); + let txt2 = std::fs::read_to_string(&path).unwrap(); + assert!(!txt2.contains("test-server")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn file_mtime_works() { + let dir = test_dir("mtime"); + let path = dir.join("test_mtime.json"); + assert!(file_mtime(&path).is_none()); + std::fs::write(&path, b"{}").unwrap(); + assert!(file_mtime(&path).is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn server_count_empty() { + let path = std::env::temp_dir().join("ns_nonexistent_12345.json"); + assert_eq!(server_count(&path), 0); + } +} diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index 8136baa..22ab635 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -195,6 +195,23 @@ fn operator_directives(cfg: &RunConfig) -> String { if !recalled.is_empty() { s.push_str(&recalled); } + // Reference methodology files: operator-supplied .md files injected as + // supplementary testing guidance. Each file is capped at 8000 chars to + // keep prompt budgets reasonable. + for path in &cfg.reference_prompts { + if let Ok(content) = std::fs::read_to_string(path) { + let fname = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path); + let truncated: String = content.chars().take(8000).collect(); + s.push_str(&format!( + "\nREFERENCE METHODOLOGY ({fname}) โ€” use as supplementary testing guidance:\n{truncated}\n" + )); + } + } + // Autonomy directives: shape how aggressively agents explore. + s.push_str(&autonomy_directives(&cfg.autonomy)); // The live attack graph โ€” refreshed at every graph checkpoint of the test. let graph = crate::graph_memory::live_context(&run_id(cfg)); if !graph.is_empty() { @@ -208,6 +225,42 @@ fn operator_directives(cfg: &RunConfig) -> String { s } +/// Generate autonomy directives based on the configured level. +fn autonomy_directives(level: &str) -> String { + match level.trim().to_lowercase().as_str() { + "cautious" => String::new(), + "aggressive" => "\n\ + AUTONOMY: AGGRESSIVE โ€” you have maximum latitude to find vulnerabilities:\n\ + - Install ANY tool you need without hesitation (apt, pip, npm, cargo, go install, git clone). Time-box installs to 120s.\n\ + - Try CREATIVE and LATERAL attack vectors beyond the standard checklist: parser differentials, race conditions, \ + type juggling, deserialization gadgets, second-order injections, cache poisoning, request smuggling.\n\ + - Chain findings aggressively: every foothold is a pivot opportunity. Explore what you can reach from each finding.\n\ + - Do NOT stop at the first test per category. Vary payloads, encodings, HTTP methods, content-types, and injection points.\n\ + - Research the identified technology stack for known CVEs and test each one specifically.\n\ + - Probe pre-production and staging environments if discovered โ€” they often have weaker controls.\n\ + - When WAF blocks a payload, try at least 3 bypass techniques before moving on.\n\ + - Test business logic exhaustively: state transitions, race conditions, price manipulation, privilege boundaries.\n\ + ".to_string(), + "full" => "\n\ + AUTONOMY: FULL โ€” zero restrictions on testing methodology:\n\ + - Install ANY tool, download ANY PoC, clone ANY exploit repo. No hesitation, no confirmation needed.\n\ + - You are an elite researcher doing ORIGINAL vulnerability discovery, not just running checklists.\n\ + - Think like a zero-day hunter: understand the system deeply, question every assumption, explore every edge case.\n\ + - EVERY parser is a target: JSON, XML, URL, header, query string, cookie, multipart, protobuf. Fuzz each one.\n\ + - Chain EVERYTHING: SSRF to cloud metadata, XSS to account takeover, IDOR to data exfiltration, SQLi to RCE.\n\ + - Test timing attacks, race conditions, TOCTOU, and concurrency issues on every state-changing endpoint.\n\ + - Explore the FULL attack surface: subdomains, pre-prod environments, exposed APIs, internal services, \ + debug endpoints, cloud metadata, CI/CD artifacts.\n\ + - When one approach fails, try at least 5 alternatives before concluding an endpoint is not vulnerable.\n\ + - WAF bypass is expected: encoding tricks, HTTP verb tampering, header injection, chunked encoding, \ + Unicode normalization, double encoding, null bytes, comment injection.\n\ + - Document zero-day discoveries with full methodology: observation, hypothesis, test, result, exploitation, confirmation.\n\ + - Use the REFERENCE METHODOLOGY (if provided) as your comprehensive testing playbook.\n\ + ".to_string(), + _ => String::new(), // "standard" = no extra directives + } +} + /// Where this project's durable state lives. The app already points /// `vault_dir` at `/.neurosploit/vault`, so its parent is the project /// store; a caller that set neither falls back to the run's own workdir, which @@ -1183,6 +1236,24 @@ pub async fn run(mut cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender } } + // MCP hot-reload: watch mcp.servers.json for mid-run changes so the + // operator can add/remove MCP servers (e.g. Playwright) without stopping. + let _mcp_watcher = if pool.mcp_config.is_some() { + if let Some(wd) = cfg.workdir.as_deref() { + let config_dir = std::path::PathBuf::from(wd); + let proj_dir = config_dir.parent().unwrap_or(&config_dir); + let watch_path = proj_dir.join("mcp.servers.json"); + let reloader = std::sync::Arc::new( + crate::mcp_hot_reload::McpReloader::new(config_dir, watch_path), + ); + Some(reloader.clone().spawn(Some(tx.clone()), pool.cancel_handle())) + } else { + None + } + } else { + None + }; + let _ = tx .send(format!( "Loaded {} agents ({} vuln / {} recon / {} code / {} meta) ยท models: {} ยท vote_n={} ยท concurrency={}{} ยท budget: {}", diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index e9342c1..8748ba2 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -308,6 +308,21 @@ pub struct RunConfig { /// they are tested directly, in addition to what recon discovers. #[serde(default)] pub api_collection: Option, + /// Paths to reference methodology files (.md) injected into every agent + /// prompt as supplementary material. Agents use these as additional + /// testing guidance alongside their own specialist playbook. + #[serde(default)] + pub reference_prompts: Vec, + /// Agent autonomy level: "cautious" (default safety, minimal installs), + /// "standard" (balanced), "aggressive" (install tools freely, creative + /// lateral testing, deeper chaining, longer timeouts), "full" (maximum + /// autonomy โ€” no guardrails on tool installs, research, creative attacks). + #[serde(default = "default_autonomy")] + pub autonomy: String, +} + +fn default_autonomy() -> String { + "standard".to_string() } fn default_vote() -> usize { @@ -369,6 +384,8 @@ impl RunConfig { creds_file: None, neo4j: None, api_collection: None, + reference_prompts: Vec::new(), + autonomy: default_autonomy(), } } }