mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-20 14:37:17 +02:00
feat(harness): add MCP hot-reload, reference prompts, and agent autonomy levels
- 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.
This commit is contained in:
1 parent
afa11bcb67
commit
e4f6b42f37
5 files changed
+295
-2
No files matched your search
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AtomicU64>,
|
||||
}
|
||||
|
||||
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<Self>,
|
||||
progress: Option<tokio::sync::mpsc::Sender<String>>,
|
||||
cancel: Arc<std::sync::atomic::AtomicBool>,
|
||||
) -> 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<usize> {
|
||||
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<u64> {
|
||||
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::<serde_json::Value>(&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::<serde_json::Value>(&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<bool> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 `<cwd>/.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: {}",
|
||||
|
||||
@@ -308,6 +308,21 @@ pub struct RunConfig {
|
||||
/// they are tested directly, in addition to what recon discovers.
|
||||
#[serde(default)]
|
||||
pub api_collection: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user