fix(repl): show recon/probe activity + don't let the idle guardrail kill recon

Symptom: with a non-streaming subscription CLI (codex), a long/intense recon
showed nothing in the feed ("phase starting") and the 5-min idle guardrail killed
the run before any agent ran.
- render_compact now SHOWS recon/probe/ai-recon/skills-audit/loaded/running lines
  (were dropped) so a long recon no longer looks frozen.
- Idle guardrail reworked: resets on ANY streamed activity (not only new
  findings) and only ARMS after exploitation starts (agent launch / vote) — recon
  can never trip it. Message: "no activity in N min".
- RunLive.ingest sets phase=recon on recon/probe lines (was stuck at "starting").
This commit is contained in:
CyberSecurityUP
2026-07-10 17:01:42 -03:00
parent d9c191ec39
commit 5b9d485025
2 changed files with 24 additions and 10 deletions
+8 -2
View File
@@ -1076,10 +1076,16 @@ pub(crate) fn render_compact(raw: &str) -> Option<String> {
"ai" => return None, // skip verbose model chatter in background feed
_ => {
let low = line.to_lowercase();
if low.contains("recon complete") { "\x1b[36m 🔍 recon complete\x1b[0m".into() }
// Recon / probe activity — SHOW it so a long recon (esp. via a
// non-streaming CLI like codex) doesn't look frozen.
if low.starts_with("probe:") { format!("\x1b[36m 🔎 {}\x1b[0m", trunc1(line, 130)) }
else if low.contains("recon complete") { "\x1b[36m 🔍 recon complete\x1b[0m".into() }
else if low.starts_with("recon") || low.starts_with("ai-recon") || low.contains("recon round") || low.contains("intensity") { format!("\x1b[36m 🔍 {}\x1b[0m", trunc1(line, 130)) }
else if low.starts_with("skills audit") || low.starts_with("ai engagement") { format!("\x1b[36m 🤖 {}\x1b[0m", trunc1(line, 130)) }
else if low.starts_with("loaded ") || low.starts_with("running ") { format!("\x1b[36m 🧭 {}\x1b[0m", trunc1(line, 130)) }
else if low.contains("selected") && low.contains("agent") { format!("\x1b[36m 🧭 {}\x1b[0m", trunc1(line, 110)) }
else if low.starts_with("vote") && low.contains("confirmed") { format!("\x1b[1;32m ✓ {}\x1b[0m", trunc1(line, 110)) }
else if low.starts_with("exploit") || low.starts_with("test ") || low.contains("launching agent") { format!("\x1b[35m 🧪 {}\x1b[0m", trunc1(line, 110)) }
else if low.starts_with("exploit") || low.starts_with("test ") || low.starts_with("ai ") || low.starts_with("skill ") || low.contains("launching agent") { format!("\x1b[35m 🧪 {}\x1b[0m", trunc1(line, 110)) }
else if low.starts_with("vote") { format!("\x1b[2m · {}\x1b[0m", trunc1(line, 110)) }
else if low.contains("fail") || low.contains("error") { format!("\x1b[31m ✗ {}\x1b[0m", trunc1(line, 110)) }
else { return None; }
+16 -8
View File
@@ -50,7 +50,7 @@ impl RunLive {
let low = line.to_lowercase();
if low.contains("token/quota exhausted") || low.contains("run is paused") { self.phase = "paused (quota)".into(); }
else if low.contains("resumed — retrying") { self.phase = "exploiting".into(); }
else if low.contains("recon complete") { self.phase = "recon".into(); }
else if low.starts_with("recon") || low.starts_with("ai-recon") || low.contains("recon round") || low.contains("intensity") || low.starts_with("probe:") { self.phase = "recon".into(); }
else if low.contains("selected") && low.contains("agent") {
self.phase = "planning".into();
if let Some(n) = line.split_whitespace().find_map(|t| t.parse::<usize>().ok()) { self.agents = n; }
@@ -1051,9 +1051,10 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
tokio::spawn(async move {
let crate::Spawned { task, mut rx, workdir, .. } = sp;
let mut last_saved = 0usize;
let mut last_find = Instant::now(); // time of the last NEW finding
let mut last_activity = Instant::now(); // last sign of PROGRESS (any activity)
let mut idle_fired = false;
let mut tool_events = 0usize; // exec/net/read/browser activity seen
let mut exploiting = false; // guardrail only arms once exploitation starts
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(15));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
@@ -1061,14 +1062,20 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
maybe = rx.recv() => {
let Some(line) = maybe else { break };
live2.lock().unwrap().ingest(&line);
let low = line.to_lowercase();
if line.contains("exec:") || line.contains("net:") || line.contains("read:") || line.contains("browser") { tool_events += 1; }
// ANY streamed line is progress → reset the idle clock (a long
// recon or active tool use must NOT count as idle).
last_activity = Instant::now();
// Exploitation has begun once agents launch / vote — only then arm the guardrail.
if low.contains("launching agent") || low.starts_with("exploit ") || low.starts_with("test ")
|| low.starts_with("ai ") || low.starts_with("skill ") || low.starts_with("vote") { exploiting = true; }
if let Some(out) = crate::render_compact(&line) { let _ = printer.print(out); }
// Checkpoint on each new finding; also resets the idle clock.
// Checkpoint on each new finding.
let snap = {
let l = live2.lock().unwrap();
if l.full.len() != last_saved {
last_saved = l.full.len();
last_find = Instant::now();
Some(LiveCheckpoint {
target: l.target.clone(), mode: l.mode.into(), phase: l.phase.clone(),
workdir: workdir.display().to_string(),
@@ -1079,15 +1086,16 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
if let Some(c) = snap { save_checkpoint(&c); }
}
_ = ticker.tick() => {
// Idle guardrail: no NEW finding within the window → soft-stop
// (stop launching exploit agents, validate what was found).
if idle_secs > 0 && !idle_fired && last_find.elapsed().as_secs() >= idle_secs
// Idle guardrail: only after exploitation started AND no activity
// (not just no finding) within the window → soft-stop & validate.
// Recon never trips it — it streams progress lines that reset the clock.
if idle_secs > 0 && !idle_fired && exploiting && last_activity.elapsed().as_secs() >= idle_secs
&& !soft_task.load(Ordering::Relaxed) && !cancel_task.load(Ordering::Relaxed) {
idle_fired = true;
*choice2.lock().unwrap() = StopMode::Validate;
soft_task.store(true, Ordering::Relaxed);
let _ = printer.print(format!(
"\x1b[33m⏹ idle guardrail: no new finding in {} min — stopping & validating what was found\x1b[0m",
"\x1b[33m⏹ idle guardrail: no activity in {} min — stopping & validating what was found\x1b[0m",
idle_secs / 60));
}
}