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:
CyberSecurityUP committed 2026-10-18 22:28:26 -03:00
1 parent afa11bcb67
commit e4f6b42f37
5 files changed
+295 -2

No files matched your search

+2 -2
View File
@@ -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",
+2
View File
@@ -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(),
}
}
}