mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-10-04 23:16:50 +02:00
feat: attack knowledge graph, layered memory, command rectification, FAIR dashboard
Backend ------- - knowledge_graph.rs — the durable structure under attack_graph's per-run view: typed entities (asset/endpoint/weakness/technique/finding/account/credential/ impact) joined by typed, weighted, provenance-carrying edges, accumulated across runs in .neurosploit/graph.json plus a per-run copy the report and web console can draw. Answers what a finding list can't: ranked attack paths, and the frontier of entities observed but never proven — where chaining should look next. Agents only sometimes fill chains_from, so progression is also inferred between adjacent kill-chain stages; those edges are marked inferred, weighted lower, and drawn dashed, because presenting a hypothesis as evidence is the graph lying about itself. Secrets stay in the vault, never the graph. - memory.rs — four tiers scoped by lifetime, not importance: working (one run), engagement (one target), technique (one agent/CWE), reusable (generalized). Promotion is evidence-gated and needs independent evidence at each step: a claim repeated within a run becomes engagement knowledge; one confirmed across runs becomes technique knowledge; one that held on two DIFFERENT targets is generalized into a reusable lesson with host-specific tokens stripped. Nothing is promoted on a single observation, which is exactly what a hallucination looks like. Recall is scored (overlap × past success × recency) and injected into recon/exploit prompts as leads to verify. Recalled memos are credited only when the run they informed actually found something. - rectify.rs — a mistyped command cost a full round trip through /help, at the worst possible moment during a live run. Accepted-as-typed wins over everything (so the /url alias is never "corrected" to /ua), then unique prefix, then Damerau-Levenshtein with a length-scaled budget, and a tie is reported rather than resolved. Arguments too: a bare host gets its scheme, an out-of-range count is clamped with a note instead of silently reverting, a near-miss model id is matched against the live catalog. - pool.rs — when every configured model is exhausted or its token is dead, try whatever else this machine can actually reach (an installed CLI subscription, or a provider whose key is in the environment) before parking. A run that stops on a box with three other usable backends stopped for no reason. - repl.rs — /memory, /forget, /graph; a recovered run resumes by itself where nobody is watching (piped stdin — the web console — or NEUROSPLOIT_AUTO_RESUME), since a `/continue` prompt there waits forever. Web --- - Attack path: the stage list was seven hardcoded values, so findings the harness staged outside it were silently dropped — 5 of 27 on a real run. Rewritten against the harness's own stage list with unknown stages kept, two-line labels (every node used to read "SQL Injection Authent…"), stage column headers, pan/zoom/fit, path highlighting, severity filter, and the run's graph.json used when present. - Dashboard: coverage, findings by severity, top weaknesses, and annualized loss exposure via FAIR — frequency from exploitability × validation confidence, magnitude from assumptions shown on screen and editable, reported as a range. The posture score saturates instead of subtracting, so it keeps discriminating past the first critical. - Run history groups into one folder per target with a filter, instead of one flat list that grows forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvdGy9XtVWSdXDTa3FFLJv
This commit is contained in:
1 parent
0ef0ce8d94
commit
9d83cb6e30
13 files changed
+3163
-119
No files matched your search
@@ -1,5 +1,6 @@
|
||||
//! NeuroSploit v4.0.0 — interactive harness + CLI (`run` / `whitebox` / `agents` / `models`).
|
||||
|
||||
mod rectify;
|
||||
mod repl;
|
||||
mod tui;
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Command rectification for the REPL.
|
||||
//!
|
||||
//! A mistyped command used to cost the operator a full round trip: `unknown
|
||||
//! command '/staus' — try /help`, then reading the help, then retyping. During a
|
||||
//! live run that is the worst possible moment to lose your place. This module
|
||||
//! turns a typo into either the command that was obviously meant, or a short
|
||||
//! list of what was probably meant — never a silent guess.
|
||||
//!
|
||||
//! Three rules, in order, and the order is the point:
|
||||
//!
|
||||
//! 1. **Accepted as typed** wins over everything. The dispatch accepts ~70
|
||||
//! literals including aliases (`/q`, `/url`, `/log`), so correction must only
|
||||
//! ever see input the dispatch would have rejected — otherwise `/url` gets
|
||||
//! "corrected" to `/ua` and a working command starts doing something else.
|
||||
//! 2. **Unique prefix**: `/stat` completes to `/status` when nothing else starts
|
||||
//! that way. This is what Tab would have done.
|
||||
//! 3. **Edit distance** with transpositions (`/staus`, `/sttaus` → `/status`),
|
||||
//! with the budget scaled to word length, and only when one candidate is
|
||||
//! strictly closer than the runner-up. A tie is ambiguity, and ambiguity is
|
||||
//! reported, not resolved — running the wrong command against a live target
|
||||
//! is worse than asking.
|
||||
//!
|
||||
//! Arguments get the same treatment where a mistake has one obvious reading: a
|
||||
//! bare host is a URL missing its scheme, and an out-of-range count is a clamp
|
||||
//! with a note, not a silent default.
|
||||
|
||||
/// What to do with an input command.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Fix {
|
||||
/// The dispatch accepts it as typed.
|
||||
Accepted,
|
||||
/// Unambiguously a typo for `to`; `note` explains the substitution.
|
||||
Corrected { to: String, note: String },
|
||||
/// Several equally plausible commands — the operator has to pick.
|
||||
Ambiguous(Vec<String>),
|
||||
/// Nothing close enough; `Vec` holds any weak suggestions (possibly empty).
|
||||
Unknown(Vec<String>),
|
||||
}
|
||||
|
||||
/// Damerau-Levenshtein (optimal string alignment) distance.
|
||||
///
|
||||
/// Plain Levenshtein scores a transposition as two edits, which is exactly the
|
||||
/// typo a fast typist makes most (`/sttaus`); counting it as one is what lets a
|
||||
/// tight budget still catch it.
|
||||
pub fn distance(a: &str, b: &str) -> usize {
|
||||
let a: Vec<char> = a.chars().collect();
|
||||
let b: Vec<char> = b.chars().collect();
|
||||
let (n, m) = (a.len(), b.len());
|
||||
if n == 0 {
|
||||
return m;
|
||||
}
|
||||
if m == 0 {
|
||||
return n;
|
||||
}
|
||||
let mut d = vec![vec![0usize; m + 1]; n + 1];
|
||||
for (i, row) in d.iter_mut().enumerate().take(n + 1) {
|
||||
row[0] = i;
|
||||
}
|
||||
for j in 0..=m {
|
||||
d[0][j] = j;
|
||||
}
|
||||
for i in 1..=n {
|
||||
for j in 1..=m {
|
||||
let cost = usize::from(a[i - 1] != b[j - 1]);
|
||||
d[i][j] = (d[i - 1][j] + 1).min(d[i][j - 1] + 1).min(d[i - 1][j - 1] + cost);
|
||||
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
|
||||
d[i][j] = d[i][j].min(d[i - 2][j - 2] + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
d[n][m]
|
||||
}
|
||||
|
||||
/// Edit budget for a word of this length. Two edits on `/ua` would reach half
|
||||
/// the command list, so short commands get a tighter budget than long ones.
|
||||
fn budget(len: usize) -> usize {
|
||||
match len {
|
||||
0..=3 => 0,
|
||||
4..=5 => 1,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide what a typed command should become. `accepted` is every literal the
|
||||
/// dispatch handles, aliases included.
|
||||
pub fn rectify_command(input: &str, accepted: &[&str]) -> Fix {
|
||||
let raw = input.trim();
|
||||
if raw.is_empty() {
|
||||
return Fix::Unknown(vec![]);
|
||||
}
|
||||
let lower = raw.to_lowercase();
|
||||
if accepted.iter().any(|c| *c == lower) {
|
||||
return Fix::Accepted;
|
||||
}
|
||||
|
||||
// A command typed without its slash (`status`) is a command, not prose —
|
||||
// prose does not collide with the dispatch table.
|
||||
let slashed = if lower.starts_with('/') { lower.clone() } else { format!("/{lower}") };
|
||||
if !lower.starts_with('/') && accepted.iter().any(|c| *c == slashed) {
|
||||
return Fix::Corrected { to: slashed.clone(), note: format!("read '{raw}' as '{slashed}'") };
|
||||
}
|
||||
|
||||
// Unique prefix — what Tab completion would have produced.
|
||||
if slashed.len() >= 3 {
|
||||
let pre: Vec<&str> = accepted.iter().copied().filter(|c| c.starts_with(&slashed)).collect();
|
||||
if pre.len() == 1 {
|
||||
return Fix::Corrected { to: pre[0].to_string(), note: format!("completed '{raw}' → '{}'", pre[0]) };
|
||||
}
|
||||
if pre.len() > 1 {
|
||||
let mut v: Vec<String> = pre.iter().map(|s| s.to_string()).collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
return Fix::Ambiguous(v);
|
||||
}
|
||||
}
|
||||
|
||||
let mut scored: Vec<(usize, &str)> = accepted.iter().map(|c| (distance(&slashed, c), *c)).collect();
|
||||
scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
|
||||
let budget = budget(slashed.len());
|
||||
let best = scored.first().copied();
|
||||
|
||||
if let Some((d0, c0)) = best {
|
||||
if d0 <= budget {
|
||||
let runner_up = scored.iter().skip(1).find(|(_, c)| *c != c0).map(|(d, _)| *d).unwrap_or(usize::MAX);
|
||||
if d0 < runner_up {
|
||||
return Fix::Corrected { to: c0.to_string(), note: format!("corrected '{raw}' → '{c0}'") };
|
||||
}
|
||||
let tied: Vec<String> = scored.iter().filter(|(d, _)| *d == d0).map(|(_, c)| c.to_string()).collect();
|
||||
return Fix::Ambiguous(tied);
|
||||
}
|
||||
}
|
||||
// Nothing within budget: offer the nearest few as a hint, not a correction.
|
||||
let hints: Vec<String> = scored.iter().filter(|(d, _)| *d <= budget + 2).take(3).map(|(_, c)| c.to_string()).collect();
|
||||
Fix::Unknown(hints)
|
||||
}
|
||||
|
||||
/// Normalize a target the way an operator meant it: add the missing scheme, fix
|
||||
/// a mistyped one, and drop trailing punctuation a shell or a paste left behind.
|
||||
/// Returns `None` when the input is already fine.
|
||||
pub fn rectify_url(input: &str) -> Option<String> {
|
||||
let raw = input.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut s = raw.trim_end_matches(['.', ',', ';', ')', '\'', '"']).to_string();
|
||||
let mut changed = s != raw;
|
||||
|
||||
// Common near-misses of the scheme, including the single-slash paste.
|
||||
for (bad, good) in [
|
||||
("htp://", "http://"),
|
||||
("htttp://", "http://"),
|
||||
("htps://", "https://"),
|
||||
("htpps://", "https://"),
|
||||
("httpss://", "https://"),
|
||||
("hhttp://", "http://"),
|
||||
("http:/", "http://"),
|
||||
("https:/", "https://"),
|
||||
] {
|
||||
if s.starts_with(bad) && !s.starts_with(good) {
|
||||
s = format!("{good}{}", &s[bad.len()..]);
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !s.contains("://") {
|
||||
// A local path is a repo, not a URL — leave it for /repo to handle.
|
||||
if s.starts_with('/') || s.starts_with("./") || s.starts_with("~") {
|
||||
return None;
|
||||
}
|
||||
s = format!("https://{s}");
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a count, clamped into range. Returns the value and an optional note
|
||||
/// explaining what was changed — an out-of-range number is a typo worth
|
||||
/// reporting, and silently falling back to the old value hides it.
|
||||
pub fn rectify_count(input: &str, min: usize, max: usize, current: usize) -> (usize, Option<String>) {
|
||||
let t = input.trim();
|
||||
if t.is_empty() {
|
||||
return (current, None);
|
||||
}
|
||||
// Tolerate "3x", "3 votes", "v3" — the digits are the intent.
|
||||
let digits: String = t.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
let Ok(n) = digits.parse::<usize>() else {
|
||||
return (current, Some(format!("'{t}' is not a number — keeping {current}")));
|
||||
};
|
||||
if n < min {
|
||||
(min, Some(format!("{n} is below the minimum — using {min}")))
|
||||
} else if n > max {
|
||||
(max, Some(format!("{n} is above the maximum — using {max}")))
|
||||
} else if digits != t {
|
||||
(n, Some(format!("read '{t}' as {n}")))
|
||||
} else {
|
||||
(n, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest `provider:model` in the catalog, for `/model` typos. Only returns a
|
||||
/// candidate when it is close enough to be the same identifier mistyped.
|
||||
pub fn nearest_model(input: &str, catalog: &[String]) -> Option<String> {
|
||||
let q = input.trim().to_lowercase();
|
||||
if q.is_empty() || catalog.iter().any(|m| m.to_lowercase() == q) {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(usize, &String)> = None;
|
||||
for m in catalog {
|
||||
let d = distance(&q, &m.to_lowercase());
|
||||
if best.map(|(bd, _)| d < bd).unwrap_or(true) {
|
||||
best = Some((d, m));
|
||||
}
|
||||
}
|
||||
// Scale with the identifier's length: `gpt-5.4` and `gpt-5.1` differ by one
|
||||
// character and are different models, so the budget has to stay tight.
|
||||
best.filter(|(d, m)| *d <= (m.len() / 6).clamp(1, 3)).map(|(_, m)| m.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ACCEPTED: &[&str] = &[
|
||||
"/help", "/status", "/stop", "/show", "/sub", "/run", "/runs", "/report", "/results",
|
||||
"/target", "/ua", "/url", "/model", "/models", "/mcp", "/only", "/onboard", "/q", "/quit",
|
||||
"/log", "/logs", "/votes", "/recon", "/repo",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn an_accepted_alias_is_never_rewritten() {
|
||||
// The regression this whole ordering exists to prevent: /url is a real
|
||||
// alias and must not be "corrected" to the nearby /ua.
|
||||
for c in ["/url", "/ua", "/q", "/log", "/models"] {
|
||||
assert_eq!(rectify_command(c, ACCEPTED), Fix::Accepted, "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transposition_is_one_edit_away() {
|
||||
assert_eq!(distance("/staus", "/status"), 1, "a dropped character");
|
||||
assert_eq!(distance("/sttaus", "/status"), 1, "a swapped pair is one edit, not two");
|
||||
assert_eq!(distance("/status", "/statsu"), 1);
|
||||
match rectify_command("/staus", ACCEPTED) {
|
||||
Fix::Corrected { to, .. } => assert_eq!(to, "/status"),
|
||||
other => panic!("expected a correction, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_unique_prefix_completes() {
|
||||
match rectify_command("/onb", ACCEPTED) {
|
||||
Fix::Corrected { to, .. } => assert_eq!(to, "/onboard"),
|
||||
other => panic!("expected completion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_prefix_asks_instead_of_guessing() {
|
||||
match rectify_command("/ru", ACCEPTED) {
|
||||
Fix::Ambiguous(v) => assert_eq!(v, vec!["/run".to_string(), "/runs".to_string()]),
|
||||
other => panic!("expected ambiguity, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_slash_is_read_as_the_command() {
|
||||
match rectify_command("status", ACCEPTED) {
|
||||
Fix::Corrected { to, .. } => assert_eq!(to, "/status"),
|
||||
other => panic!("expected /status, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_is_not_forced_onto_a_command() {
|
||||
match rectify_command("/zzzzzzzz", ACCEPTED) {
|
||||
Fix::Unknown(_) => {}
|
||||
other => panic!("expected unknown, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_commands_get_no_edit_budget() {
|
||||
// With a budget, "/ub" would land on "/ua" or "/sub" — both wrong, and
|
||||
// both a command that changes how the engagement runs.
|
||||
match rectify_command("/ub", ACCEPTED) {
|
||||
Fix::Unknown(_) => {}
|
||||
other => panic!("expected unknown for a 3-char typo, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_get_the_scheme_they_were_missing() {
|
||||
assert_eq!(rectify_url("example.com").as_deref(), Some("https://example.com"));
|
||||
assert_eq!(rectify_url("htp://example.com").as_deref(), Some("http://example.com"));
|
||||
assert_eq!(rectify_url("https:/example.com").as_deref(), Some("https://example.com"));
|
||||
assert_eq!(rectify_url("https://example.com/x,").as_deref(), Some("https://example.com/x"));
|
||||
assert_eq!(rectify_url("https://example.com"), None, "a correct URL is left alone");
|
||||
assert_eq!(rectify_url("/opt/src/repo"), None, "a local path is not a URL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_clamp_and_say_so() {
|
||||
assert_eq!(rectify_count("3", 1, 9, 3), (3, None));
|
||||
let (v, note) = rectify_count("99", 1, 9, 3);
|
||||
assert_eq!(v, 9);
|
||||
assert!(note.unwrap().contains("above the maximum"));
|
||||
let (v, note) = rectify_count("banana", 1, 9, 4);
|
||||
assert_eq!(v, 4);
|
||||
assert!(note.unwrap().contains("not a number"));
|
||||
let (v, note) = rectify_count("5 votes", 1, 9, 3);
|
||||
assert_eq!(v, 5);
|
||||
assert!(note.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_near_model_id_is_offered_but_a_sibling_version_is_not() {
|
||||
let catalog: Vec<String> = vec!["openai:gpt-5.4".into(), "anthropic:claude-opus-5".into()];
|
||||
assert_eq!(nearest_model("openai:gpt5.4", &catalog).as_deref(), Some("openai:gpt-5.4"));
|
||||
assert_eq!(nearest_model("openai:gpt-5.4", &catalog), None, "an exact id needs no fix");
|
||||
}
|
||||
}
|
||||
@@ -139,12 +139,32 @@ struct LiveCheckpoint {
|
||||
commands: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every literal the dispatch below accepts, aliases included.
|
||||
///
|
||||
/// [`COMMANDS`] is the *discoverable* subset offered by Tab completion; this is
|
||||
/// the full set, and command rectification needs the full set: correcting input
|
||||
/// the dispatch would have accepted (`/url`, `/q`, `/log`) into some
|
||||
/// near-neighbour would break working commands. A test keeps the two in sync.
|
||||
pub(crate) const ACCEPTED: &[&str] = &[
|
||||
"/?", "/agents", "/attach", "/auth", "/burp", "/chain", "/changed", "/clear", "/config",
|
||||
"/context", "/continue", "/creds", "/diff", "/exclude", "/exit", "/expand", "/feed",
|
||||
"/finding", "/findings", "/focus", "/forget", "/full", "/go", "/goal", "/graph", "/help",
|
||||
"/history", "/idle", "/instructions", "/integration", "/integrations", "/key", "/log",
|
||||
"/logs", "/mcp", "/memory", "/model", "/models", "/objective", "/objectives", "/offline",
|
||||
"/onboard", "/only", "/oos", "/outofscope", "/providers", "/proxy", "/q", "/quit", "/recon",
|
||||
"/repo", "/report", "/results", "/resume", "/retest", "/revalidate", "/run", "/runs",
|
||||
"/scope", "/scope-out", "/show", "/status", "/stop", "/sub", "/subscription", "/target",
|
||||
"/temp-email", "/tempmail", "/theme", "/timeout", "/ua", "/url", "/useragent", "/validate",
|
||||
"/votes",
|
||||
];
|
||||
|
||||
/// All slash-commands, for Tab completion.
|
||||
const COMMANDS: &[&str] = &[
|
||||
"/help", "/onboard", "/show", "/config", "/providers", "/model", "/key", "/sub", "/target",
|
||||
"/repo", "/auth", "/creds", "/focus", "/objective", "/scope-out", "/attach", "/context", "/mcp", "/offline",
|
||||
"/votes", "/chain", "/recon", "/tempmail", "/timeout", "/proxy", "/burp", "/ua", "/agents", "/only", "/theme", "/clear", "/run", "/stop", "/continue", "/runs", "/results", "/report",
|
||||
"/status", "/logs", "/diff", "/retest", "/validate", "/finding", "/expand", "/integrations", "/quit",
|
||||
"/status", "/logs", "/diff", "/retest", "/validate", "/finding", "/expand", "/integrations",
|
||||
"/memory", "/forget", "/graph", "/quit",
|
||||
];
|
||||
|
||||
/// rustyline helper: Tab-completes `/commands` and `@filesystem-paths`,
|
||||
@@ -397,6 +417,8 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
// A recovered interrupted run, carried in memory so `/continue` can relaunch
|
||||
// the engagement on the same target with these findings folded forward.
|
||||
let mut resumable: Option<(String, Vec<Finding>)> = None;
|
||||
// Set when a recovered run should continue without waiting for a human.
|
||||
let mut auto_resume = false;
|
||||
// Recover an interrupted run (REPL was quit/crashed mid-engagement): its
|
||||
// live findings were checkpointed to disk — fold them into /runs so
|
||||
// /results, /finding and /report still work.
|
||||
@@ -411,8 +433,19 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
save_runs(base, &h);
|
||||
println!(" \x1b[1;33m↻ recovered interrupted run on {} — {} finding(s) saved as run #{}\x1b[0m (/results {id} · /report {id})",
|
||||
cp.target, cp.findings.len(), id);
|
||||
println!(" \x1b[36m ↳ /continue to keep testing this target — the {} finding(s) carry forward\x1b[0m", cp.findings.len());
|
||||
resumable = Some((cp.target.clone(), cp.findings.clone()));
|
||||
// Resume by itself where nobody is watching: the web console drives
|
||||
// this REPL over a pipe, and a run that stops there waits forever
|
||||
// for a `/continue` no one will type. An interactive operator keeps
|
||||
// the choice — relaunching an engagement spends tokens, and at a
|
||||
// real terminal there is someone to decide.
|
||||
auto_resume = !std::io::stdin().is_terminal()
|
||||
|| std::env::var("NEUROSPLOIT_AUTO_RESUME").map(|v| v == "1" || v == "true").unwrap_or(false);
|
||||
if auto_resume {
|
||||
println!(" \x1b[36m ↳ resuming automatically — the {} finding(s) carry forward\x1b[0m", cp.findings.len());
|
||||
} else {
|
||||
println!(" \x1b[36m ↳ /continue to keep testing this target — the {} finding(s) carry forward\x1b[0m", cp.findings.len());
|
||||
}
|
||||
}
|
||||
clear_checkpoint();
|
||||
}
|
||||
@@ -420,6 +453,12 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
let mut reader = Reader::new(base);
|
||||
let mut active: Option<ActiveRun> = None;
|
||||
let mut queue: Vec<String> = Vec::new(); // remaining targets for a multi-target /run
|
||||
// Commands to run before reading from the user — how an auto-resumed run
|
||||
// re-enters the normal dispatch instead of duplicating /continue's logic.
|
||||
let mut pending: Vec<String> = Vec::new();
|
||||
if auto_resume && resumable.is_some() {
|
||||
pending.push("/continue".into());
|
||||
}
|
||||
// First-launch onboarding: pick scope (web/infra/cloud/ai/skills) → box → setup.
|
||||
if s.target.is_none() && s.repo.is_none() && std::io::stdin().is_terminal() {
|
||||
onboarding(&mut s);
|
||||
@@ -434,7 +473,14 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
active = start_background(base, &s, &mut reader, history.clone(), Some(&next), vec![]).await;
|
||||
}
|
||||
println!("{}", context_prompt(&s)); // dim context line above the prompt
|
||||
let Some(line) = reader.read(PROMPT) else { println!("\n bye."); break };
|
||||
let line = if pending.is_empty() {
|
||||
let Some(l) = reader.read(PROMPT) else { println!("\n bye."); break };
|
||||
l
|
||||
} else {
|
||||
let l = pending.remove(0);
|
||||
println!("{PROMPT}{l}");
|
||||
l
|
||||
};
|
||||
// Ctrl-C → confirm before doing anything drastic (don't lose a live run).
|
||||
if line == CTRL_C {
|
||||
let run_active = active.as_ref().map(|a| !a.done.load(Ordering::Relaxed)).unwrap_or(false);
|
||||
@@ -481,7 +527,30 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
let (cmd, arg) = (cmd.as_str(), arg.as_str());
|
||||
// Rectify before dispatch, so the match below only ever sees a command
|
||||
// it handles. A typo mid-run costs an operator their place in the
|
||||
// output; correcting the obvious ones — and asking about the rest —
|
||||
// keeps a slip from becoming a round trip through /help.
|
||||
let cmd_owned = match crate::rectify::rectify_command(&cmd, ACCEPTED) {
|
||||
crate::rectify::Fix::Accepted => cmd.clone(),
|
||||
crate::rectify::Fix::Corrected { to, note } => {
|
||||
println!(" \x1b[2m↻ {note}\x1b[0m");
|
||||
to
|
||||
}
|
||||
crate::rectify::Fix::Ambiguous(v) => {
|
||||
println!(" '{cmd}' matches {} commands: {}", v.len(), v.join(" "));
|
||||
continue;
|
||||
}
|
||||
crate::rectify::Fix::Unknown(hints) => {
|
||||
if hints.is_empty() {
|
||||
println!(" unknown command '{cmd}' — /help lists them all");
|
||||
} else {
|
||||
println!(" unknown command '{cmd}' — did you mean {}?", hints.join(", "));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (cmd, arg) = (cmd_owned.as_str(), arg.as_str());
|
||||
match cmd {
|
||||
"/help" | "/?" => help(),
|
||||
"/show" | "/config" => show(&s),
|
||||
@@ -496,7 +565,17 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
if arg.is_empty() {
|
||||
pick_models(&mut s);
|
||||
} else {
|
||||
s.models = arg.split([',', ' ']).filter(|x| !x.is_empty()).map(String::from).collect();
|
||||
// A model id is long and easy to fumble; an unrecognized one
|
||||
// otherwise fails much later, inside the run.
|
||||
let catalog: Vec<String> = harness::providers().iter()
|
||||
.flat_map(|p| p.models.iter().map(move |m| format!("{}:{}", p.key, m)))
|
||||
.collect();
|
||||
s.models = arg.split([',', ' ']).filter(|x| !x.is_empty()).map(|x| {
|
||||
match crate::rectify::nearest_model(x, &catalog) {
|
||||
Some(fixed) => { println!(" \x1b[2m↻ corrected '{x}' → '{fixed}'\x1b[0m"); fixed }
|
||||
None => x.to_string(),
|
||||
}
|
||||
}).collect();
|
||||
println!(" models: {}", s.models.join(", "));
|
||||
}
|
||||
// If a run is paused on exhaustion, queue the newly-chosen models
|
||||
@@ -518,9 +597,11 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
if arg.is_empty() { println!(" target: {}", s.target.clone().unwrap_or_else(|| "(none) — set with /target <url[,url2,...]>, clear with /target clear".into())); }
|
||||
else if arg == "clear" { s.target = None; println!(" target cleared"); }
|
||||
else {
|
||||
// Accept one URL or a comma-separated list; normalize each.
|
||||
// Accept one URL or a comma-separated list; normalize each —
|
||||
// a missing scheme, a mistyped one (`htp://`, `https:/`) or
|
||||
// a trailing comma from a paste all resolve to one reading.
|
||||
let ts: Vec<String> = arg.split(',').map(|x| x.trim()).filter(|x| !x.is_empty())
|
||||
.map(|x| if x.starts_with("http") { x.to_string() } else { format!("https://{x}") })
|
||||
.map(|x| crate::rectify::rectify_url(x).unwrap_or_else(|| x.to_string()))
|
||||
.collect();
|
||||
s.target = Some(ts.join(","));
|
||||
if ts.len() > 1 { println!(" targets ({}): {}", ts.len(), ts.join(", ")); println!(" \x1b[2m/run tests them sequentially, one report each\x1b[0m"); }
|
||||
@@ -640,7 +721,14 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
"/mcp" => { s.mcp = !matches!(arg, "off" | "false" | "0" | "no"); println!(" Playwright MCP: {}", onoff(s.mcp)); }
|
||||
"/offline" => { s.offline = !matches!(arg, "off" | "false" | "0" | "no"); println!(" offline: {}", onoff(s.offline)); }
|
||||
"/integrations" | "/integration" => integrations_cmd(arg),
|
||||
"/votes" => { s.vote_n = arg.parse().unwrap_or(s.vote_n); println!(" votes: {}", s.vote_n); }
|
||||
"/votes" => {
|
||||
// Out of range used to fall back to the current value in
|
||||
// silence, so `/votes 30` looked applied and wasn't.
|
||||
let (n, note) = crate::rectify::rectify_count(arg, 1, 9, s.vote_n);
|
||||
if let Some(note) = note { println!(" \x1b[2m↻ {note}\x1b[0m"); }
|
||||
s.vote_n = n;
|
||||
println!(" votes: {}", s.vote_n);
|
||||
}
|
||||
"/chain" => {
|
||||
if arg.is_empty() { println!(" attack-chain depth: {} (0 disables) — set with /chain <n>", s.chain_depth); }
|
||||
else { s.chain_depth = arg.parse().unwrap_or(s.chain_depth); println!(" attack-chain depth: {}", s.chain_depth); }
|
||||
@@ -925,7 +1013,23 @@ pub async fn repl(base: &Path) -> anyhow::Result<()> {
|
||||
}
|
||||
save_session(&s); println!(" session saved → {} · bye.", proj_dir().display()); break;
|
||||
}
|
||||
other => println!(" unknown command '{other}' — try /help"),
|
||||
"/memory" => memory_cmd(&s, arg),
|
||||
"/forget" => {
|
||||
if arg.trim().is_empty() {
|
||||
println!(" usage: /forget <text> — drops every memory whose text contains it");
|
||||
} else {
|
||||
let mut mem = harness::memory::Memory::open(proj_dir().join("memory"));
|
||||
let n = mem.forget(arg.trim());
|
||||
println!(" forgot {n} memo(s) matching '{}'", arg.trim());
|
||||
}
|
||||
}
|
||||
"/graph" => {
|
||||
let g = harness::knowledge_graph::KnowledgeGraph::load(proj_dir().join("graph.json"));
|
||||
print!("{}", g.summary());
|
||||
}
|
||||
// Rectification only forwards commands listed in ACCEPTED, so
|
||||
// reaching here means ACCEPTED lists something this match forgot.
|
||||
other => println!(" '{other}' is listed but not implemented — please report this."),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1348,6 +1452,47 @@ fn merge_findings(prior: Vec<Finding>, mut fresh: Vec<Finding>) -> Vec<Finding>
|
||||
fresh
|
||||
}
|
||||
|
||||
/// `/memory` — inspect what the harness has learned, or search it.
|
||||
///
|
||||
/// The four tiers are shown separately because they mean different things: an
|
||||
/// engagement memo is about *this* target, a reusable one is a lesson that
|
||||
/// already held on two of them. Collapsing them into one list would hide the
|
||||
/// distinction that makes the promotion ladder worth having.
|
||||
fn memory_cmd(s: &Session, arg: &str) {
|
||||
let mem = harness::memory::Memory::open(proj_dir().join("memory"));
|
||||
let (w, e, t, r) = mem.counts();
|
||||
let q = arg.trim();
|
||||
if q.is_empty() {
|
||||
println!(" ┌ memory · working {w} · engagement {e} · technique {t} · reusable {r}");
|
||||
let recent = mem.dump();
|
||||
if recent.is_empty() {
|
||||
println!(" │ (nothing learned yet — memory fills in as runs finish)");
|
||||
}
|
||||
for m in recent.iter().take(12) {
|
||||
println!(" │ [{:<10} {:>3}%] {}", m.tier.as_str(), (m.confidence * 100.0) as u32, trunc(&m.text, 92));
|
||||
}
|
||||
if recent.len() > 12 {
|
||||
println!(" │ … {} more · /memory <text> to search", recent.len() - 12);
|
||||
}
|
||||
println!(" └ /forget <text> removes matching memos");
|
||||
return;
|
||||
}
|
||||
let hits = mem.recall(&harness::memory::Query {
|
||||
text: q.to_string(),
|
||||
target: s.target.clone().unwrap_or_default(),
|
||||
limit: 15,
|
||||
..Default::default()
|
||||
});
|
||||
if hits.is_empty() {
|
||||
println!(" no memory matches '{q}'");
|
||||
return;
|
||||
}
|
||||
println!(" ── {} match(es) for '{q}' ──", hits.len());
|
||||
for h in hits {
|
||||
println!(" [{:.2}] \x1b[2m{:<10}\x1b[0m {}", h.score, h.memo.tier.as_str(), trunc(&h.memo.text, 96));
|
||||
}
|
||||
}
|
||||
|
||||
/// Project-local store: `<cwd>/.neurosploit/` so each project keeps its own
|
||||
/// session, run history and command history (resume on reopen). No DB needed —
|
||||
/// it's structured state, not semantic search.
|
||||
@@ -1760,6 +1905,11 @@ fn help() {
|
||||
h("/retest [n]", "re-verify a past run's findings (re-runs the test)");
|
||||
h("/validate [n]", "false-positive validate a recovered/past run (no re-test)");
|
||||
|
||||
println!("\n \x1b[2mKNOWLEDGE\x1b[0m");
|
||||
h("/memory [text]", "what the harness learned (working·engagement·technique·reusable); search with text");
|
||||
h("/forget <text>", "drop every memory whose text matches");
|
||||
h("/graph", "attack knowledge graph: entities, top attack paths, unproven frontier");
|
||||
|
||||
println!("\n \x1b[2mINTEGRATIONS\x1b[0m");
|
||||
h("/integrations", "show · enable/disable github|gitlab|jira · setup <name>");
|
||||
|
||||
@@ -2287,4 +2437,23 @@ mod nl_tests {
|
||||
assert_eq!(parse_intent_fast("recon 4 em example.com").0.recon, Some(4));
|
||||
}
|
||||
|
||||
/// Rectification forwards only what ACCEPTED lists, so anything offered by
|
||||
/// Tab completion but missing from ACCEPTED would become unreachable — the
|
||||
/// user would type a real command and be told it doesn't exist.
|
||||
#[test]
|
||||
fn every_completable_command_is_accepted_by_the_dispatch() {
|
||||
let missing: Vec<&&str> = COMMANDS.iter().filter(|c| !ACCEPTED.contains(c)).collect();
|
||||
assert!(missing.is_empty(), "completed but not dispatchable: {missing:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_commands_survive_rectification_untouched() {
|
||||
for c in ACCEPTED {
|
||||
assert_eq!(
|
||||
crate::rectify::rectify_command(c, ACCEPTED),
|
||||
crate::rectify::Fix::Accepted,
|
||||
"{c} must reach the dispatch as typed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user