From e267afb7b65f10bef59338490e9e8810f5e8a4b7 Mon Sep 17 00:00:00 2001 From: Joas A Santos <34966120+JoasASantos@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:26:17 -0300 Subject: [PATCH] feat: engagement objective + out-of-scope context for prompts (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two operator inputs that give agents more test context, both funneled through operator_directives() so they reach every recon/ exploit prompt (web, host, ai, skills): - objective: WHY the test runs and WHAT counts as impact — rendered as high-priority ENGAGEMENT OBJECTIVE context. - out_of_scope: hosts/paths/techniques to exclude — rendered as a HARD CONSTRAINT the agents must skip and never report against. REPL: /objective and /scope-out commands (accumulating), optional onboarding prompts, /show + /help + Tab-complete, session.json persistence (serde default for back-compat). CLI: neurosploit run --objective --out-of-scope. Version unchanged (3.6.5). Claude-Session: https://claude.ai/code/session_018BGLy4j5qsqqid6CoovowC Co-authored-by: Claude Opus 4.8 --- neurosploit-rs/app/src/main.rs | 11 +++- neurosploit-rs/app/src/repl.rs | 57 ++++++++++++++++++- neurosploit-rs/crates/harness/src/pipeline.rs | 8 +++ neurosploit-rs/crates/harness/src/types.rs | 13 +++++ 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/neurosploit-rs/app/src/main.rs b/neurosploit-rs/app/src/main.rs index e11299e..708990e 100644 --- a/neurosploit-rs/app/src/main.rs +++ b/neurosploit-rs/app/src/main.rs @@ -67,6 +67,13 @@ enum Cmd { /// Free-text focus, e.g. "injection and broken access control". #[arg(long)] focus: Option, + /// Engagement objective / context: WHY the test runs and WHAT matters. + #[arg(long)] + objective: Option, + /// Out-of-scope exclusions (hard constraint): hosts/paths/techniques the + /// agents must not touch. Repeatable or comma/semicolon-separated. + #[arg(long = "out-of-scope")] + out_of_scope: Option, /// Open a Jira card per finding (needs the jira integration enabled). #[arg(long)] jira: bool, @@ -367,7 +374,7 @@ async fn main() -> anyhow::Result<()> { } } } - Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, jira, verbose } => { + Cmd::Run { url, models, max_agents, vote_n, chain_depth, recon, offline, subscription, mcp, creds, focus, objective, out_of_scope, jira, verbose } => { let url = if url.starts_with("http") { url } else { format!("https://{url}") }; let mut cfg = RunConfig::new(&url); cfg.max_agents = max_agents; @@ -378,6 +385,8 @@ async fn main() -> anyhow::Result<()> { cfg.subscription = subscription; cfg.verbose = verbose; cfg.instructions = focus; + cfg.objective = objective; + cfg.out_of_scope = out_of_scope; if !models.is_empty() { cfg.models = models; } diff --git a/neurosploit-rs/app/src/repl.rs b/neurosploit-rs/app/src/repl.rs index 0446fe9..68798a6 100644 --- a/neurosploit-rs/app/src/repl.rs +++ b/neurosploit-rs/app/src/repl.rs @@ -141,7 +141,7 @@ struct LiveCheckpoint { /// All slash-commands, for Tab completion. const COMMANDS: &[&str] = &[ "/help", "/onboard", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target", - "/repo", "/auth", "/creds", "/focus", "/attach", "/context", "/mcp", "/offline", + "/repo", "/auth", "/creds", "/focus", "/objective", "/scope-out", "/attach", "/context", "/mcp", "/offline", "/votes", "/chain", "/recon", "/tempmail", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report", "/status", "/logs", "/diff", "/retest", "/validate", "/finding", "/expand", "/integrations", "/quit", ]; @@ -255,6 +255,10 @@ struct Session { roles: Vec<(String, String)>, creds: Option, instructions: Option, + /// Engagement objective / rules-of-engagement context (why + what matters). + objective: Option, + /// Explicit out-of-scope exclusions the agents must not touch. + out_of_scope: Option, attachments: Vec, color: bool, /// Engagement scope from onboarding: web | infra | cloud | ai | skills. @@ -282,6 +286,8 @@ impl Default for Session { roles: Vec::new(), creds: None, instructions: None, + objective: None, + out_of_scope: None, attachments: Vec::new(), color: true, scope: "web", @@ -582,6 +588,28 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> { s.instructions = Some(arg.to_string()); println!(" focus: {}", s.instructions.clone().unwrap_or_else(|| "(none)".into())); } + "/objective" | "/goal" | "/objectives" => { + if arg == "clear" { s.objective = None; println!(" objective cleared"); continue; } + if arg.is_empty() { + println!(" objective: {}", s.objective.clone().unwrap_or_else(|| "(none) — set the engagement goal/context with /objective ".into())); + continue; + } + s.objective = Some(arg.to_string()); + println!(" objective set — steers what agents prioritise and what counts as impact"); + } + "/scope-out" | "/outofscope" | "/oos" | "/exclude" => { + if arg == "clear" { s.out_of_scope = None; println!(" out-of-scope cleared"); continue; } + if arg.is_empty() { + println!(" out-of-scope: {}", s.out_of_scope.clone().unwrap_or_else(|| "(none) — exclude hosts/paths/techniques with /scope-out ".into())); + continue; + } + // Append to any existing exclusions rather than overwrite (comma-joined). + s.out_of_scope = Some(match &s.out_of_scope { + Some(prev) if !prev.trim().is_empty() => format!("{prev}; {arg}"), + _ => arg.to_string(), + }); + println!(" out-of-scope: {} \x1b[2m(hard constraint — agents skip these)\x1b[0m", s.out_of_scope.clone().unwrap_or_default()); + } "/attach" => { let n = attach_path(arg.trim_start_matches('@'), &mut s); if n > 0 { println!(" attached ({} total)", s.attachments.len()); } } "/context" => { if s.attachments.is_empty() { println!(" no attachments — add with @path or /attach "); } @@ -939,6 +967,19 @@ fn onboarding(s: &mut Session) { } _ => { s.scope = "web"; println!(" (manual setup — use /target /repo /creds /auth then /run)"); } } + // Optional: capture engagement objective + out-of-scope. Both feed the agent + // prompts as context (objective) and a hard constraint (out-of-scope). Empty + // input skips — nothing is required to /run. + let obj = ask_line(" Objective / context for this test [enter to skip]:"); + if !obj.trim().is_empty() { + s.objective = Some(obj.trim().to_string()); + println!(" ✓ objective set — steers what agents prioritise."); + } + let oos = ask_line(" Out of scope — hosts/paths/techniques to EXCLUDE [enter to skip]:"); + if !oos.trim().is_empty() { + s.out_of_scope = Some(oos.trim().to_string()); + println!(" ✓ out-of-scope set — agents will skip these (hard constraint)."); + } } fn pick_models(s: &mut Session) { @@ -1052,6 +1093,8 @@ async fn run(base: &Path, s: &Session, history: &mut Vec) { Some(format!("{}\n\nATTACHED CONTEXT:\n{ctx}", instr.unwrap_or_default())) } }; + cfg.objective = s.objective.clone(); + cfg.out_of_scope = s.out_of_scope.clone(); cfg.auth = s.auth.clone(); // Multiple /auth identities → prepend the access-control (IDOR/BOLA/BFLA) directive. if let Some(rd) = roles_directive(&s.roles) { @@ -1124,6 +1167,8 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader, cfg.offline = s.offline; cfg.instructions = if s.attachments.is_empty() { s.instructions.clone() } else { Some(format!("{}\n\nATTACHED CONTEXT:\n{}", s.instructions.clone().unwrap_or_default(), s.attachments.join("\n\n"))) }; + cfg.objective = s.objective.clone(); + cfg.out_of_scope = s.out_of_scope.clone(); cfg.auth = s.auth.clone(); if matches!(mode_e, crate::Mode::Grey) { cfg.repo = s.repo.clone(); } crate::apply_creds(&mut cfg, s.creds.as_deref()).await; @@ -1315,6 +1360,10 @@ struct Snapshot { auth: Option, creds: Option, instructions: Option, + #[serde(default)] + objective: Option, + #[serde(default)] + out_of_scope: Option, } fn session_path() -> std::path::PathBuf { proj_dir().join("session.json") } fn save_session(s: &Session) { @@ -1323,6 +1372,7 @@ fn save_session(s: &Session) { vote_n: s.vote_n, max_agents: s.max_agents, target: s.target.clone(), repo: s.repo.clone(), auth: s.auth.clone(), creds: s.creds.clone(), instructions: s.instructions.clone(), + objective: s.objective.clone(), out_of_scope: s.out_of_scope.clone(), }; if let Ok(j) = serde_json::to_string_pretty(&snap) { std::fs::write(session_path(), j).ok(); } } @@ -1335,6 +1385,7 @@ fn load_session(s: &mut Session) -> bool { s.max_agents = snap.max_agents; s.target = snap.target; s.repo = snap.repo; s.auth = snap.auth; s.creds = snap.creds; s.instructions = snap.instructions; + s.objective = snap.objective; s.out_of_scope = snap.out_of_scope; true } @@ -1611,6 +1662,8 @@ fn show(s: &Session) { println!(" │ proxy : {}", s.proxy.clone().unwrap_or_else(|| "(none — /proxy for Burp/ZAP)".into())); println!(" │ user-agent: {}", s.user_agent.clone().unwrap_or_else(|| "NeuroSploit (default)".into())); println!(" │ focus : {}", s.instructions.clone().unwrap_or_else(|| "(none — tests everything)".into())); + println!(" │ objective: {}", s.objective.clone().unwrap_or_else(|| "(none — /objective )".into())); + println!(" │ out-scope: {}", s.out_of_scope.clone().unwrap_or_else(|| "(none — /scope-out )".into())); println!(" │ opts : mcp={} offline={} votes={} recon={} chain-depth={} max-agents={} idle-stop={} temp-email={}", onoff(s.mcp), onoff(s.offline), s.vote_n, s.recon_intensity, s.chain_depth, s.max_agents, if s.idle_secs == 0 { "off".to_string() } else { format!("{}m", s.idle_secs / 60) }, onoff(s.temp_email)); @@ -1648,6 +1701,8 @@ fn help() { h("/auth ", "auth header (Bearer/cookie/key). Roles: /auth admin · /auth user "); h("/creds ", "creds: jwt/header/cookie/login + ssh/windows + aws/gcp/azure + roles"); h("/focus ", "steer the tests (or just type the instruction)"); + h("/objective ", "engagement goal/context — shapes what agents prioritise & count as impact"); + h("/scope-out ", "out-of-scope exclusions — hard constraint, agents skip these (clear to reset)"); h("@path @dir @f:1-20", "attach a file/folder/line-range to context (Tab → menu)"); h("/attach ", "attach a file/folder to context"); h("/context", "list current attachments"); diff --git a/neurosploit-rs/crates/harness/src/pipeline.rs b/neurosploit-rs/crates/harness/src/pipeline.rs index d34b6d7..4d09f64 100644 --- a/neurosploit-rs/crates/harness/src/pipeline.rs +++ b/neurosploit-rs/crates/harness/src/pipeline.rs @@ -35,9 +35,17 @@ Base everything on real observed responses — never assume. Reply with a COMPAC /// recon/exploit prompts so the engagement is steered as the user asked. fn operator_directives(cfg: &RunConfig) -> String { let mut s = String::new(); + if let Some(obj) = cfg.objective.as_deref().filter(|x| !x.trim().is_empty()) { + s.push_str(&format!("ENGAGEMENT OBJECTIVE — the goal and context of this test; let it shape what you prioritise and what counts as impact: {obj}\n")); + } if let Some(focus) = cfg.instructions.as_deref().filter(|x| !x.trim().is_empty()) { s.push_str(&format!("OPERATOR FOCUS — prioritise this: {focus}\n")); } + if let Some(oos) = cfg.out_of_scope.as_deref().filter(|x| !x.trim().is_empty()) { + s.push_str(&format!( + "OUT OF SCOPE — HARD CONSTRAINT, do NOT test, probe, or interact with any of the following; \ + skip them entirely even if reachable, and never report findings against them: {oos}\n")); + } if let Some(auth) = cfg.auth.as_deref().filter(|x| !x.trim().is_empty()) { s.push_str(&format!("AUTHENTICATION — test as an authenticated user; send this with each request: {auth}\n")); } diff --git a/neurosploit-rs/crates/harness/src/types.rs b/neurosploit-rs/crates/harness/src/types.rs index 818ff54..f2ae12b 100644 --- a/neurosploit-rs/crates/harness/src/types.rs +++ b/neurosploit-rs/crates/harness/src/types.rs @@ -140,6 +140,17 @@ pub struct RunConfig { /// execution (e.g. "focus on injection and broken access control"). #[serde(default)] pub instructions: Option, + /// Engagement objective / rules-of-engagement context: WHY this test is run + /// and WHAT matters (e.g. "pre-launch review of the checkout flow; prove any + /// path to unauthorized order access"). Prepended to prompts as high-priority + /// context so agents understand the goal, not just the surface. + #[serde(default)] + pub objective: Option, + /// Explicit out-of-scope exclusions the agents MUST NOT touch (e.g. hosts, + /// paths, techniques, or actions). Rendered as a hard constraint in every + /// recon/exploit prompt. Optional; empty means "nothing excluded". + #[serde(default)] + pub out_of_scope: Option, /// Authentication material to use against the target so agents test as an /// authenticated user (e.g. "Authorization: Bearer " or "Cookie: session=..."). #[serde(default)] @@ -209,6 +220,8 @@ impl RunConfig { rl_path: None, verbose: false, instructions: None, + objective: None, + out_of_scope: None, auth: None, repo: None, pinned: Vec::new(),