feat: proof screenshots in reports (finding-correlated) + source-able env.sh (#37)

* feat: embed proof screenshots in reports, correlated to findings

Define a convention that ties each proof image to its vulnerability and
renders it in every report format.

- Finding gains `screenshots: Vec<String>` (paths relative to the run
  workdir, e.g. evidence/<finding-id>-1.png).
- Exploit prompt injects an EVIDENCE SCREENSHOTS doctrine: agents save
  proof PNGs into the run's absolute evidence/ dir named by a vuln slug,
  and list them in the finding JSON `screenshots` array.
- collect_evidence() resolves whatever the agent captured (absolute,
  workdir-relative, evidence/, /tmp basename), copies it to a stable
  evidence/<finding-id>-N.png, and rewrites the field; unresolved refs
  are dropped so a report never embeds a missing image.
- Typst (image()), HTML (<img>) and Markdown (![]) render each finding's
  screenshots beside its evidence.

Tests: slugify + collect_evidence resolution/rename; verified a real PDF
compiles with an embedded image.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BGLy4j5qsqqid6CoovowC

* feat: source-able env.sh to activate neurosploit in the current shell

Add env.sh: `source` it to export NEUROSPLOIT (binary path),
NEUROSPLOIT_BASE (agents base) and prepend the binary dir to PATH —
no reinstall or new terminal needed. Auto-detects the install/repo dir,
honors NEUROSPLOIT_DIR, idempotent. setup.sh now writes a ready env.sh
into the install dir and points users at `source <dir>/env.sh`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018BGLy4j5qsqqid6CoovowC

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Joas A Santos
2026-07-31 20:32:15 -03:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b55b5fa32e
commit 4eed1ce652
6 changed files with 286 additions and 6 deletions
Executable
+52
View File
@@ -0,0 +1,52 @@
# NeuroSploit environment — source this to use `neurosploit` in the CURRENT shell
# without reinstalling or opening a new terminal:
#
# source env.sh # from a repo checkout or an install dir
# source ~/.neurosploit-app/env.sh
#
# It exports:
# NEUROSPLOIT_BASE the app/agents base dir (agents_md lives here)
# NEUROSPLOIT full path to the neurosploit binary
# PATH prepended with the binary's dir so `neurosploit` resolves
#
# Honors NEUROSPLOIT_DIR to point at a custom install dir. Safe to source twice.
# Resolve where this script lives (works when sourced from bash or zsh).
if [ -n "${BASH_SOURCE:-}" ]; then _ns_self="${BASH_SOURCE[0]}"
elif [ -n "${ZSH_VERSION:-}" ]; then _ns_self="${(%):-%N}"
else _ns_self="$0"; fi
_ns_here="$(cd "$(dirname "$_ns_self")" >/dev/null 2>&1 && pwd)"
# Pick the base dir: explicit override → this script's dir → default install dir.
_ns_base="${NEUROSPLOIT_DIR:-$_ns_here}"
# Find the binary: alongside the base, in a repo release build, or on PATH.
_ns_bin=""
for _c in \
"$_ns_base/neurosploit" \
"$_ns_here/neurosploit" \
"$_ns_here/neurosploit-rs/target/release/neurosploit" \
"$_ns_here/target/release/neurosploit" \
"$HOME/.neurosploit-app/neurosploit"
do
if [ -x "$_c" ]; then _ns_bin="$_c"; break; fi
done
if [ -z "$_ns_bin" ] && command -v neurosploit >/dev/null 2>&1; then
_ns_bin="$(command -v neurosploit)"
fi
if [ -z "$_ns_bin" ]; then
echo "neurosploit binary not found — run setup.sh (or set NEUROSPLOIT_DIR) first." >&2
else
# agents_md sits next to the binary unless the base already has it.
if [ -d "$_ns_base/agents_md" ]; then :; else _ns_base="$(dirname "$_ns_bin")"; fi
export NEUROSPLOIT_BASE="$_ns_base"
export NEUROSPLOIT="$_ns_bin"
case ":$PATH:" in
*":$(dirname "$_ns_bin"):"*) : ;; # already on PATH
*) export PATH="$(dirname "$_ns_bin"):$PATH" ;;
esac
echo "NeuroSploit ready — NEUROSPLOIT=$NEUROSPLOIT · NEUROSPLOIT_BASE=$NEUROSPLOIT_BASE"
fi
unset _ns_self _ns_here _ns_base _ns_bin _c
+185 -2
View File
@@ -504,6 +504,13 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
let _ = tx.send("recon: SPA/REST surface detected — applying API-hunting methodology".into()).await;
SPA_API_DOCTRINE
} else { "" };
// Absolute evidence dir + the screenshot-correlation convention for the prompt.
let evidence_dir = cfg.workdir.as_deref().map(|d| {
let p = Path::new(d).join("evidence");
let _ = std::fs::create_dir_all(&p);
std::fs::canonicalize(&p).unwrap_or(p).display().to_string()
}).unwrap_or_default();
let shots = if evidence_dir.is_empty() { String::new() } else { screenshot_doctrine(&evidence_dir) };
// Token economy: each agent gets a capped recon context, not the full blob.
let recon_ctx: String = recon.chars().take(3500).collect();
let raw: Vec<(String, String, Vec<Finding>)> = stream::iter(selected.iter().cloned())
@@ -512,6 +519,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
let recon = recon_ctx.clone();
let directives = directives.clone();
let ops = ops.clone();
let shots = shots.clone();
let txc = tx.clone();
async move {
if pool.stop_exploiting() {
@@ -523,9 +531,10 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
let user = format!(
"AUTHORIZED engagement — you have explicit permission to test {target}. \
Do not ask for confirmation — proceed and PROVE each issue.\n\n\
{directives}{react}{depth}{decision}{spa}{safety}{ops}{doctrine}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence,auth_context,account,secret}}. \
{directives}{react}{depth}{decision}{spa}{safety}{ops}{doctrine}{shots}{body}\n\nWhen done, reply with ONLY a JSON array of confirmed findings (may be empty []). \
Each item: {{id,title,severity,cwe,endpoint,payload,evidence,impact,remediation,confidence,auth_context,account,secret,screenshots}}. \
`evidence` must contain the concrete proof (request/response excerpt). \
`screenshots` is an array of proof-image paths you saved into the evidence dir (see EVIDENCE SCREENSHOTS above); omit or leave empty when you captured none. \
Set `auth_context` to \"authenticated\" or \"unauthenticated\"; set `account` to the test user/role you used (if any); \
for a created test account set `secret` to its generated password (it is stored in the run vault and masked in the report).",
target = target,
@@ -534,6 +543,7 @@ pub async fn run(cfg: RunConfig, lib: &Library, pool: &ModelPool, tx: Sender<Str
depth = DEPTH_DOCTRINE, decision = DECISION_DOCTRINE, spa = spa, safety = SAFETY_DOCTRINE,
ops = ops,
doctrine = tool_doctrine(mcp_on),
shots = shots,
body = ag.user.replace("{target}", &target).replace("{recon_json}", &recon),
);
match pool.complete_routed(Task::Exploit, &ag.name, &ag.system, &user).await {
@@ -1248,6 +1258,14 @@ async fn finish(cfg: RunConfig, _lib: &Library, recon: String, transcript: Strin
stamp_attribution(&mut findings);
// Map findings to OWASP / MITRE / kill-chain stage for the attack graph.
crate::attack_graph::enrich(&mut findings);
// Collect proof screenshots into evidence/<finding-id>-N.png so the report
// can embed each image beside its vulnerability.
if let Some(dir) = cfg.workdir.as_deref() {
let imgs = collect_evidence(&mut findings, Path::new(dir));
if imgs > 0 {
let _ = tx.send(format!("notify: 📸 {imgs} proof screenshot(s) collected → evidence/")).await;
}
}
// RL update (robust reward shaping): an agent's reward per run =
// + strong for each CONFIRMED finding (severity × confidence),
@@ -1391,12 +1409,128 @@ fn extract_findings(text: &str, agent: &str) -> Vec<Finding> {
confidence: conf(o.get("confidence")),
validated: false,
votes: String::new(),
screenshots: screenshot_refs(o),
..Default::default()
})
})
.collect()
}
/// Pull screenshot path(s) an agent referenced in its finding JSON. Accepts a
/// single `screenshot` string or a `screenshots` array (any scalar coerced to
/// string). These are raw refs (whatever the agent named/where it saved); the
/// evidence-collection pass later resolves and renames them to stable,
/// finding-correlated paths under the run's `evidence/` dir.
fn screenshot_refs(o: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
let mut out = Vec::new();
match o.get("screenshots") {
Some(serde_json::Value::Array(a)) => {
for v in a {
let p = match v {
serde_json::Value::String(t) => t.trim().to_string(),
_ => v.to_string(),
};
if !p.is_empty() { out.push(p); }
}
}
Some(serde_json::Value::String(t)) if !t.trim().is_empty() => out.push(t.trim().to_string()),
_ => {}
}
let one = s(o, "screenshot");
if !one.is_empty() && !out.contains(&one) { out.push(one); }
out
}
/// The convention prompt that tells agents WHERE to drop proof screenshots and
/// HOW to reference them, so each image can be correlated back to its finding.
/// `evidence_dir` is the absolute `<workdir>/evidence` path (already created).
fn screenshot_doctrine(evidence_dir: &str) -> String {
format!(
"EVIDENCE SCREENSHOTS (correlate each image to its finding):\n\
- When you PROVE a finding visually (XSS firing, an admin panel reached, \
data exposed, a client-side auth bypass), capture a screenshot as proof.\n\
- Save every proof PNG into this exact directory: `{dir}` (it already exists). \
Name each file after the vulnerability using a short kebab-case slug, e.g. \
`{dir}/reflected-xss-search.png`, `{dir}/idor-order-42.png`.\n\
- In that finding's JSON, add a `screenshots` array listing the file paths you saved \
(absolute like `{dir}/idor-order-42.png`, or just the basename `idor-order-42.png`). \
One image per distinct proof; multiple allowed. Omit the field when you have no image.\n\
- The screenshot must belong to THAT finding — never reuse one image across unrelated findings.\n\n",
dir = evidence_dir,
)
}
/// Resolve, dedupe and copy each finding's referenced proof screenshots into
/// `<workdir>/evidence/<finding-id>-<n>.png`, rewriting `Finding.screenshots` to
/// those stable, run-relative paths. Unresolved refs are dropped (so the report
/// never embeds a missing image). Returns the number of images collected.
fn collect_evidence(findings: &mut [Finding], workdir: &Path) -> usize {
let evidence = workdir.join("evidence");
if std::fs::create_dir_all(&evidence).is_err() { return 0; }
let mut total = 0usize;
for f in findings.iter_mut() {
if f.screenshots.is_empty() { continue; }
let slug = slugify(if f.id.is_empty() { &f.title } else { &f.id });
let mut stable = Vec::new();
let mut n = 0usize;
for raw in f.screenshots.clone() {
let Some(src) = resolve_screenshot(&raw, workdir, &evidence) else { continue };
n += 1;
let ext = src.extension().and_then(|e| e.to_str()).unwrap_or("png").to_lowercase();
let fname = format!("{slug}-{n}.{ext}");
let dst = evidence.join(&fname);
// Copy unless the agent already wrote it exactly there.
let ok = src == dst || std::fs::copy(&src, &dst).is_ok();
if ok {
stable.push(format!("evidence/{fname}"));
total += 1;
}
}
stable.dedup();
f.screenshots = stable;
}
total
}
/// Find the actual file an agent referenced, trying the sensible locations a
/// screenshot could have landed in: absolute, relative to the workdir, inside
/// the evidence dir, or by basename in the evidence dir / `/tmp`.
fn resolve_screenshot(raw: &str, workdir: &Path, evidence: &Path) -> Option<PathBuf> {
let is_img = |p: &Path| p.is_file()
&& matches!(p.extension().and_then(|e| e.to_str()).map(|e| e.to_lowercase()).as_deref(),
Some("png" | "jpg" | "jpeg" | "webp" | "gif"));
let base = Path::new(raw).file_name().map(PathBuf::from);
let mut cands: Vec<PathBuf> = vec![
PathBuf::from(raw),
workdir.join(raw),
evidence.join(raw),
];
if let Some(b) = &base {
cands.push(evidence.join(b));
cands.push(workdir.join(b));
cands.push(Path::new("/tmp").join(b));
}
cands.into_iter().find(|p| is_img(p))
}
/// Filesystem-safe kebab slug for correlating an image filename to a finding.
fn slugify(s: &str) -> String {
let mut out = String::new();
let mut dash = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
dash = false;
} else if !dash && !out.is_empty() {
out.push('-');
dash = true;
}
}
let trimmed = out.trim_matches('-');
let slug: String = trimmed.chars().take(48).collect();
if slug.is_empty() { "finding".into() } else { slug }
}
/// Coerce any JSON scalar to a trimmed string.
fn s(o: &serde_json::Map<String, serde_json::Value>, k: &str) -> String {
match o.get(k) {
@@ -1811,3 +1945,52 @@ pub async fn run_skills_audit(cfg: RunConfig, lib: &Library, pool: &ModelPool, t
let findings = validate(candidates, pool, CODE_VOTE_SYS, cfg.vote_n, &tx).await;
finish(cfg, lib, "{}".into(), transcript, findings, agents, &mut rl, crate::grounding::GroundMode::Symbolic, context, tx).await
}
#[cfg(test)]
mod evidence_tests {
use super::*;
use crate::types::Finding;
fn write_png(p: &Path) {
// Minimal valid 1x1 PNG.
const PNG: &[u8] = &[
0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0x00,0x00,0x00,0x0d,0x49,0x48,0x44,0x52,
0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x08,0x02,0x00,0x00,0x00,0x90,0x77,0x53,
0xde,0x00,0x00,0x00,0x0c,0x49,0x44,0x41,0x54,0x08,0xd7,0x63,0xf8,0xcf,0xc0,0x00,
0x00,0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x49,0x45,0x4e,
0x44,0xae,0x42,0x60,0x82,
];
std::fs::write(p, PNG).unwrap();
}
#[test]
fn slugify_correlates_filename_to_finding() {
assert_eq!(slugify("Reflected XSS in /search?q="), "reflected-xss-in-search-q");
assert_eq!(slugify("IDOR \u{2014} order #42"), "idor-order-42");
assert_eq!(slugify("!!!"), "finding");
}
#[test]
fn collect_evidence_resolves_and_renames_by_finding_id() {
let base = std::env::temp_dir().join(format!("nrs-ev-{}", std::process::id()));
let wd = base.join("run");
let ev = wd.join("evidence");
std::fs::create_dir_all(&ev).unwrap();
write_png(&ev.join("whatever-agent-named-it.png"));
let mut fs = vec![Finding {
id: "xss-search".into(),
title: "Reflected XSS".into(),
screenshots: vec!["whatever-agent-named-it.png".into()],
..Default::default()
}];
let n = collect_evidence(&mut fs, &wd);
assert_eq!(n, 1);
assert_eq!(fs[0].screenshots, vec!["evidence/xss-search-1.png".to_string()]);
assert!(wd.join("evidence/xss-search-1.png").is_file());
let mut miss = vec![Finding { id: "x".into(), title: "t".into(),
screenshots: vec!["nope.png".into()], ..Default::default() }];
assert_eq!(collect_evidence(&mut miss, &wd), 0);
assert!(miss[0].screenshots.is_empty());
let _ = std::fs::remove_dir_all(&base);
}
}
+18 -2
View File
@@ -75,11 +75,17 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String
"<section class=finding><h3><span class=sev style=background:{}>{}</span> {}. {}{review}</h3>\
<div class=m>{} · {} · CVSS {} · votes {} · conf {:.2}</div>\
<div class=m>Endpoint: {}</div>{authline}{reviewnote}\
<h4>Payload</h4><pre>{}</pre><h4>Evidence</h4><pre>{}</pre>\
<h4>Payload</h4><pre>{}</pre><h4>Evidence</h4><pre>{}</pre>{shots}\
<h4>Impact</h4><p>{}</p><h4>Remediation</h4><p>{}</p></section>",
sev_color(&f.severity), esc(&f.severity), i + 1, esc(&f.title),
esc(&f.agent), esc(&f.cwe), esc(&f.cvss), esc(&f.votes), f.confidence,
esc(&f.endpoint), esc(&f.payload), esc(&f.evidence), esc(&f.impact), esc(&f.remediation),
shots = if f.screenshots.is_empty() { String::new() } else {
let imgs: String = f.screenshots.iter()
.map(|p| format!("<figure class=shot><img src=\"{}\" alt=\"proof for {}\"><figcaption>{}</figcaption></figure>",
esc(p), esc(&f.title), esc(p))).collect();
format!("<h4>Proof screenshots</h4><div class=shots>{imgs}</div>")
},
review = if needs_review(f) { " <span class=sev style=background:#8e44ad>NEEDS REVIEW</span>" } else { "" },
reviewnote = if needs_review(f) && !f.review_reason.is_empty() {
format!("<div class=m style=color:#8e44ad>⚠ Needs human review — {}</div>", esc(&f.review_reason))
@@ -128,6 +134,9 @@ pub fn html(target: &str, findings: &[Finding], meta: &EngagementMeta) -> String
.sev{{color:#fff;border-radius:6px;padding:2px 8px;font-size:12px;margin-right:8px}}.m{{color:#666;font-size:12px}}\
pre{{background:#0f1117;color:#dfe6f3;padding:11px;border-radius:8px;overflow:auto;font-size:12.5px}}\
h4{{margin:12px 0 3px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:#8b5cf6}}\
.shots{{display:flex;flex-wrap:wrap;gap:12px;margin:6px 0}}\
.shot{{margin:0;max-width:100%}}.shot img{{max-width:100%;border:1px solid #e3e3e3;border-radius:8px;display:block}}\
.shot figcaption{{color:#888;font-size:11px;margin-top:3px;font-family:ui-monospace,Menlo,monospace}}\
.b{{color:#8b5cf6;font-weight:800}}</style></head><body>\
<h1><span class=b>NeuroSploit</span> Penetration Test Report</h1>\
<div class=meta>Asset: <b>{asset}</b> · Target: <b>{t}</b>{techline} · v3.6.5 · multi-model validated</div>\
@@ -188,12 +197,15 @@ pub fn typst_report(target: &str, findings: &[Finding], dir: &Path) -> std::io::
for f in &sorted {
let owasp = if f.owasp.is_empty() { f.cwe.clone() } else { f.owasp.clone() };
let status = if needs_review(f) { "needs-review" } else { "confirmed" };
let shots = format!("({})",
f.screenshots.iter().map(|p| format!("{},", tq(p))).collect::<String>());
data.push_str(&format!(
" (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}),\n",
" (severity: {}, title: {}, agent: {}, cwe: {}, owasp: {}, cvss: {}, endpoint: {}, payload: {}, evidence: {}, impact: {}, remediation: {}, votes: {}, confidence: {}, status: {}, auth: {}, screenshots: {}),\n",
tq(&f.severity), tq(&f.title), tq(&f.agent), tq(&f.cwe), tq(&owasp), tq(&f.cvss),
tq(&f.endpoint), tq(&f.payload), tq(&f.evidence), tq(&f.impact),
tq(&f.remediation), tq(&f.votes), f.confidence, tq(status),
tq(if f.auth_context.is_empty() { "-" } else { &f.auth_context }),
shots,
));
}
data.push_str(")\n\n");
@@ -280,6 +292,10 @@ pub fn markdown(target: &str, findings: &[Finding], meta: &EngagementMeta) -> St
if !f.endpoint.is_empty() { s.push_str(&format!("**Endpoint:** `{}`\n\n", f.endpoint)); }
if !f.payload.is_empty() { s.push_str(&format!("**Payload**\n```\n{}\n```\n\n", f.payload)); }
if !f.evidence.is_empty() { s.push_str(&format!("**Evidence**\n```\n{}\n```\n\n", f.evidence)); }
if !f.screenshots.is_empty() {
s.push_str("**Proof screenshots**\n\n");
for p in &f.screenshots { s.push_str(&format!("![{}]({})\n\n", f.title.replace(']', ")"), p)); }
}
if !f.impact.is_empty() { s.push_str(&format!("**Impact:** {}\n\n", f.impact)); }
if !f.remediation.is_empty() { s.push_str(&format!("**Remediation:** {}\n\n", f.remediation)); }
s.push_str("---\n\n");
@@ -70,6 +70,13 @@ pub struct Finding {
/// "below vote quorum", "no receipt", "failed adversarial refute").
#[serde(default)]
pub review_reason: String,
/// Proof screenshots for this finding, as paths RELATIVE to the run workdir
/// (e.g. "evidence/<finding-id>-1.png"). Populated by the evidence-collection
/// pass, which resolves whatever the agent captured and copies it under the
/// run's `evidence/` dir with a deterministic, finding-correlated name so the
/// report can embed each image next to its vulnerability.
#[serde(default)]
pub screenshots: Vec<String>,
}
impl Default for Finding {
@@ -100,6 +107,7 @@ impl Default for Finding {
secret: String::new(),
review_status: String::new(),
review_reason: String::new(),
screenshots: Vec::new(),
}
}
}
+11
View File
@@ -143,6 +143,17 @@
#v(4pt) #strong[Description / Impact] #linebreak() #text(9pt)[#f.impact]
#v(4pt) #strong[Proof of Concept] #linebreak() #raw(f.payload)
#v(3pt) #strong[Evidence] #linebreak() #raw(f.evidence)
#let shots = f.at("screenshots", default: ())
#if shots.len() > 0 [
#v(4pt) #strong[Proof Screenshots]
#for sp in shots [
#v(3pt)
#block(breakable: false, width: 100%)[
#box(stroke: 0.5pt + rgb("#dddddd"), radius: 4pt, clip: true, image(sp, width: 100%))
#v(2pt) #text(7pt, fill: gray, font: "Menlo")[#sp]
]
]
]
#v(3pt) #strong[Remediation] #linebreak() #text(9pt)[#f.remediation]
]
#v(8pt)
+12 -2
View File
@@ -120,6 +120,15 @@ fi
mkdir -p "$PREFIX"
ln -sf "$DIR/neurosploit" "$PREFIX/neurosploit"
ok "Linked → $PREFIX/neurosploit"
# ---- write a source-able env script (activate in the CURRENT shell) ----
cat > "$DIR/env.sh" <<EOF
# NeuroSploit env — \`source\` this to use neurosploit in the current shell.
export NEUROSPLOIT_BASE="$DIR"
export NEUROSPLOIT="$DIR/neurosploit"
case ":\$PATH:" in *":$PREFIX:"*) : ;; *) export PATH="$PREFIX:\$PATH" ;; esac
EOF
ok "Wrote env script → $DIR/env.sh (source it to activate now)"
ok "Version: $(NEUROSPLOIT_BASE="$DIR" "$DIR/neurosploit" --version 2>/dev/null || echo neurosploit)"
# ---- persist env (PATH + NEUROSPLOIT_BASE) so it runs from any folder ----
@@ -152,8 +161,9 @@ for t in curl nmap rustscan ffuf node npx typst; do
done
echo
ok "Installed. Open a NEW terminal — or run now with:"
echo " export NEUROSPLOIT_BASE=\"$DIR\"; export PATH=\"$PREFIX:\$PATH\""
ok "Installed. Open a NEW terminal — or activate now with:"
echo " source \"$DIR/env.sh\""
echo " (equivalently: export NEUROSPLOIT_BASE=\"$DIR\"; export PATH=\"$PREFIX:\$PATH\")"
echo " then, from ANY folder:"
echo " neurosploit # interactive session"
echo " neurosploit run http://testphp.vulnweb.com/ --subscription --model anthropic:claude-opus-4-8 -v"