zeph_sanitizer/shadow_memory.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Per-session append-only event store for cross-turn trajectory analysis.
5//!
6//! [`ShadowMemory`] detects multi-turn attacks that distribute payload across several turns,
7//! which are invisible to the stateless [`TurnCausalAnalyzer`](super::causal_ipi::TurnCausalAnalyzer)
8//! single-batch analysis.
9//!
10//! The drift score is computed over a sliding window of the most recent events. When
11//! [`GoalDriftResult::should_alert`] is `true`, emit a `WARN` log and push a
12//! [`SecurityEventCategory::GoalDrift`](zeph_common::SecurityEventCategory::GoalDrift) event.
13//! This module never blocks execution.
14//!
15//! # Examples
16//!
17//! ```rust
18//! use zeph_sanitizer::shadow_memory::{ShadowMemory, ShadowEvent};
19//! use zeph_config::ShadowMemoryConfig;
20//!
21//! let config = ShadowMemoryConfig { enabled: true, ..Default::default() };
22//! let mut mem = ShadowMemory::new(&config).expect("enabled");
23//!
24//! mem.record(ShadowEvent {
25//! turn: 0,
26//! tools: vec!["shell".to_owned()],
27//! max_permission_class: 2,
28//! deviation_score: 0.1,
29//! goal_summary: "I will search for files.".to_owned(),
30//! });
31//!
32//! assert_eq!(mem.len(), 1);
33//! // Single event → no drift (need at least 2).
34//! let result = mem.goal_drift_score();
35//! assert!(result.score < 0.01);
36//! assert!(!result.should_alert);
37//! ```
38
39use std::collections::{HashSet, VecDeque};
40
41use zeph_config::ShadowMemoryConfig;
42
43/// Maximum characters retained from `goal_summary` on ingestion.
44const GOAL_SUMMARY_MAX_CHARS: usize = 100;
45
46/// A single safety-relevant observation recorded after a tool batch completes.
47///
48/// Events are appended to [`ShadowMemory`] in monotonic turn order. The fields capture
49/// the most goal-relevant signals without requiring an additional LLM call.
50///
51/// `goal_summary` is truncated to `GOAL_SUMMARY_MAX_CHARS` on ingestion by
52/// [`ShadowMemory::record`], so callers do not need to truncate themselves.
53#[derive(Clone)]
54pub struct ShadowEvent {
55 /// Monotonic turn index within the session (0-based).
56 pub turn: u32,
57 /// Tool names executed in this batch.
58 pub tools: Vec<String>,
59 /// Maximum permission class across all tools in this batch.
60 ///
61 /// 0 = read, 1 = write, 2 = execute, 3 = network.
62 pub max_permission_class: u8,
63 /// Causal deviation score from [`TurnCausalAnalyzer`](super::causal_ipi::TurnCausalAnalyzer).
64 ///
65 /// 0.0 when causal IPI is disabled or probes failed.
66 pub deviation_score: f32,
67 /// First `GOAL_SUMMARY_MAX_CHARS` characters of the pre-probe response.
68 ///
69 /// Empty string when no pre-probe was available (causal IPI disabled).
70 /// An empty `goal_summary` triggers maximum Jaccard drift penalty.
71 pub goal_summary: String,
72}
73
74impl std::fmt::Debug for ShadowEvent {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.debug_struct("ShadowEvent")
77 .field("turn", &self.turn)
78 .field("tools", &self.tools)
79 .field("max_permission_class", &self.max_permission_class)
80 .field("deviation_score", &self.deviation_score)
81 .field("goal_summary", &"[redacted]")
82 .finish()
83 }
84}
85
86/// Result returned by [`ShadowMemory::goal_drift_score`].
87///
88/// Callers must check `should_alert` rather than comparing `score` directly —
89/// this prevents accidentally skipping the threshold comparison.
90#[derive(Debug, Clone, Copy)]
91pub struct GoalDriftResult {
92 /// Drift score in `[0.0, 1.0]`. Higher = more trajectory deviation.
93 pub score: f32,
94 /// `true` when `score >= drift_threshold`. Caller should emit a `WARN` log
95 /// and push a [`SecurityEventCategory::GoalDrift`](zeph_common::SecurityEventCategory::GoalDrift) event.
96 pub should_alert: bool,
97}
98
99/// Append-only per-session event store for cross-turn goal trajectory analysis.
100///
101/// Create via [`ShadowMemory::new`] with a [`ShadowMemoryConfig`]. Returns `None` when
102/// the config has `enabled = false`, so callers can wrap it in `Option<ShadowMemory>`.
103///
104/// Wired into the agent tool executor via `crates/zeph-core/src/agent/tool_execution/tier_loop.rs`:
105/// after every tool batch completes, `goal_drift_score()` is called and a
106/// [`zeph_common::SecurityEventCategory::GoalDrift`] security event is emitted when an alert occurs.
107///
108/// # Examples
109///
110/// ```rust
111/// use zeph_sanitizer::shadow_memory::{ShadowMemory, ShadowEvent};
112/// use zeph_config::ShadowMemoryConfig;
113///
114/// let config = ShadowMemoryConfig { enabled: true, ..Default::default() };
115/// let mut mem = ShadowMemory::new(&config).expect("enabled");
116///
117/// // Returns None when disabled.
118/// let config_off = ShadowMemoryConfig { enabled: false, ..Default::default() };
119/// assert!(ShadowMemory::new(&config_off).is_none());
120/// ```
121pub struct ShadowMemory {
122 events: VecDeque<ShadowEvent>,
123 config: ShadowMemoryConfig,
124}
125
126impl ShadowMemory {
127 /// Construct a new [`ShadowMemory`] from config.
128 ///
129 /// Returns `None` when `config.enabled` is `false`.
130 ///
131 /// # Examples
132 ///
133 /// ```rust
134 /// use zeph_sanitizer::shadow_memory::ShadowMemory;
135 /// use zeph_config::ShadowMemoryConfig;
136 ///
137 /// let config = ShadowMemoryConfig { enabled: true, ..Default::default() };
138 /// assert!(ShadowMemory::new(&config).is_some());
139 /// ```
140 #[must_use]
141 pub fn new(config: &ShadowMemoryConfig) -> Option<Self> {
142 if !config.enabled {
143 return None;
144 }
145 Some(Self {
146 events: VecDeque::new(),
147 config: config.clone(),
148 })
149 }
150
151 /// Append a safety event after a tool batch completes.
152 ///
153 /// Evicts the oldest event with O(1) cost when `max_events` is reached.
154 /// Truncates `event.goal_summary` to 100 characters at a UTF-8 boundary.
155 ///
156 /// # Examples
157 ///
158 /// ```rust
159 /// use zeph_sanitizer::shadow_memory::{ShadowMemory, ShadowEvent};
160 /// use zeph_config::ShadowMemoryConfig;
161 ///
162 /// let config = ShadowMemoryConfig { enabled: true, max_events: 2, ..Default::default() };
163 /// let mut mem = ShadowMemory::new(&config).unwrap();
164 ///
165 /// mem.record(ShadowEvent { turn: 0, tools: vec![], max_permission_class: 0,
166 /// deviation_score: 0.0, goal_summary: "task A".to_owned() });
167 /// mem.record(ShadowEvent { turn: 1, tools: vec![], max_permission_class: 0,
168 /// deviation_score: 0.0, goal_summary: "task B".to_owned() });
169 /// mem.record(ShadowEvent { turn: 2, tools: vec![], max_permission_class: 0,
170 /// deviation_score: 0.0, goal_summary: "task C".to_owned() });
171 ///
172 /// assert_eq!(mem.len(), 2);
173 /// ```
174 pub fn record(&mut self, mut event: ShadowEvent) {
175 // Truncate goal_summary at ingestion — callers should not be responsible for this.
176 if event.goal_summary.len() > GOAL_SUMMARY_MAX_CHARS {
177 let boundary = event
178 .goal_summary
179 .floor_char_boundary(GOAL_SUMMARY_MAX_CHARS);
180 event.goal_summary.truncate(boundary);
181 }
182 // Guard: max_events=0 is rejected by config validation, but be defensive.
183 if self.config.max_events == 0 {
184 return;
185 }
186 if self.events.len() >= self.config.max_events {
187 self.events.pop_front(); // O(1) with VecDeque
188 }
189 self.events.push_back(event);
190 }
191
192 /// Compute the goal drift score over the trailing window.
193 ///
194 /// Returns a [`GoalDriftResult`] with both the raw score and a pre-computed alert flag.
195 /// Callers must use `result.should_alert` to decide whether to emit a security event —
196 /// do not compare `result.score` against the threshold directly.
197 ///
198 /// Returns score `0.0` / `should_alert = false` when fewer than 2 events are recorded
199 /// (no baseline to compare).
200 ///
201 /// # Algorithm
202 ///
203 /// 1. **Semantic drift**: average pairwise Jaccard distance between consecutive
204 /// `goal_summary` values in the window. Empty summaries produce maximum distance.
205 /// 2. **Permission escalation**: `+0.3` when `max_permission_class` increases from
206 /// window start to window end.
207 /// 3. **Deviation accumulation**: fraction of events where `deviation_score` exceeds
208 /// `drift_threshold * 0.5`.
209 ///
210 /// Weighted combination: `0.5 * semantic_drift + 0.25 * perm_escalation + 0.25 * deviation_ratio`.
211 ///
212 /// Note: Jaccard distance is gameable by synonym substitution (known v1 limitation).
213 ///
214 /// # Examples
215 ///
216 /// ```rust
217 /// use zeph_sanitizer::shadow_memory::{ShadowMemory, ShadowEvent};
218 /// use zeph_config::ShadowMemoryConfig;
219 ///
220 /// let config = ShadowMemoryConfig { enabled: true, ..Default::default() };
221 /// let mut mem = ShadowMemory::new(&config).unwrap();
222 ///
223 /// // Fewer than 2 events → 0.0, no alert
224 /// let result = mem.goal_drift_score();
225 /// assert!(result.score < 1e-6);
226 /// assert!(!result.should_alert);
227 /// ```
228 #[tracing::instrument(skip(self), fields(window_len, drift_score))]
229 #[must_use]
230 pub fn goal_drift_score(&self) -> GoalDriftResult {
231 let window_size = self.config.window_size.min(self.events.len());
232 let skip = self.events.len() - window_size;
233
234 if window_size < 2 {
235 tracing::Span::current().record("window_len", window_size);
236 tracing::Span::current().record("drift_score", 0.0_f32);
237 return GoalDriftResult {
238 score: 0.0,
239 should_alert: false,
240 };
241 }
242
243 // Collect window as a contiguous slice via make_contiguous (zero-copy when possible).
244 // We work on a temporary clone to keep &self immutable.
245 let window: Vec<&ShadowEvent> = self.events.iter().skip(skip).collect();
246
247 // 1. Semantic drift: average consecutive Jaccard distance.
248 // Record-then-score order invariant: events are appended before this is called,
249 // so window[i] precedes window[i+1] chronologically.
250 let pairs = window.len() - 1;
251 #[allow(clippy::cast_precision_loss)]
252 let semantic_drift: f32 = window
253 .windows(2)
254 .map(|w| jaccard_distance(&w[0].goal_summary, &w[1].goal_summary))
255 .sum::<f32>()
256 / pairs as f32;
257
258 // 2. Permission escalation: +0.3 if permission class increased over window.
259 // window.len() >= 2 is guaranteed by the early return above.
260 let perm_first = window[0].max_permission_class;
261 let perm_last = window[window.len() - 1].max_permission_class;
262 let perm_escalation = if perm_last > perm_first {
263 0.3_f32
264 } else {
265 0.0_f32
266 };
267
268 // 3. Deviation accumulation: fraction of events above half the drift threshold.
269 let half_threshold = self.config.drift_threshold * 0.5;
270 #[allow(clippy::cast_precision_loss)]
271 let deviation_ratio = window
272 .iter()
273 .filter(|e| e.deviation_score > half_threshold)
274 .count() as f32
275 / window.len() as f32;
276
277 let score = (0.5 * semantic_drift + 0.25 * perm_escalation + 0.25 * deviation_ratio)
278 .clamp(0.0, 1.0);
279
280 tracing::Span::current().record("window_len", window.len());
281 tracing::Span::current().record("drift_score", score);
282
283 GoalDriftResult {
284 score,
285 should_alert: score >= self.config.drift_threshold,
286 }
287 }
288
289 /// Returns a reference to the config used to construct this instance.
290 #[must_use]
291 pub fn config(&self) -> &ShadowMemoryConfig {
292 &self.config
293 }
294
295 /// Number of recorded events.
296 ///
297 /// # Examples
298 ///
299 /// ```rust
300 /// use zeph_sanitizer::shadow_memory::{ShadowMemory, ShadowEvent};
301 /// use zeph_config::ShadowMemoryConfig;
302 ///
303 /// let config = ShadowMemoryConfig { enabled: true, ..Default::default() };
304 /// let mut mem = ShadowMemory::new(&config).unwrap();
305 /// assert_eq!(mem.len(), 0);
306 /// ```
307 #[must_use]
308 pub fn len(&self) -> usize {
309 self.events.len()
310 }
311
312 /// Returns `true` when no events have been recorded.
313 ///
314 /// # Examples
315 ///
316 /// ```rust
317 /// use zeph_sanitizer::shadow_memory::ShadowMemory;
318 /// use zeph_config::ShadowMemoryConfig;
319 ///
320 /// let config = ShadowMemoryConfig { enabled: true, ..Default::default() };
321 /// let mem = ShadowMemory::new(&config).unwrap();
322 /// assert!(mem.is_empty());
323 /// ```
324 #[must_use]
325 pub fn is_empty(&self) -> bool {
326 self.events.is_empty()
327 }
328}
329
330/// Classify a tool name into a permission class for shadow memory tracking.
331///
332/// Returns:
333/// - `0` — read-only (e.g., `cat`, `ls`, `find`, `read`, `get`, `search`)
334/// - `1` — write (e.g., `write`, `create`, `edit`, `delete`, `rm`, `mv`, `cp`)
335/// - `2` — execute (e.g., `shell`, `bash`, `exec`, `run`, `python`, `node`)
336/// - `3` — network (e.g., `curl`, `http`, `fetch`, `web`, `upload`, `smtp`)
337///
338/// Falls back to `0` for unknown tool names.
339///
340/// # Examples
341///
342/// ```rust
343/// use zeph_sanitizer::shadow_memory::classify_tool_permission;
344///
345/// assert_eq!(classify_tool_permission("shell"), 2);
346/// assert_eq!(classify_tool_permission("read_file"), 0);
347/// assert_eq!(classify_tool_permission("http_get"), 3);
348/// assert_eq!(classify_tool_permission("write_file"), 1);
349/// ```
350#[must_use]
351pub fn classify_tool_permission(tool_name: &str) -> u8 {
352 let name = tool_name.to_lowercase();
353 // Network tools (highest priority check).
354 if name.contains("http")
355 || name.contains("curl")
356 || name.contains("fetch")
357 || name.contains("web")
358 || name.contains("upload")
359 || name.contains("smtp")
360 || name.contains("request")
361 || name.contains("download")
362 {
363 return 3;
364 }
365 // Execute tools.
366 if name.contains("shell")
367 || name.contains("bash")
368 || name.contains("exec")
369 || name == "run"
370 || name.contains("python")
371 || name.contains("node")
372 || name.contains("ruby")
373 || name.contains("powershell")
374 {
375 return 2;
376 }
377 // Write tools.
378 if name.contains("write")
379 || name.contains("create")
380 || name.contains("edit")
381 || name.contains("delete")
382 || name.contains("remove")
383 || name == "rm"
384 || name == "mv"
385 || name == "cp"
386 || name.contains("patch")
387 || name.contains("update")
388 || name.contains("insert")
389 {
390 return 1;
391 }
392 // Default: read-only.
393 0
394}
395
396/// Jaccard distance on word sets: `1.0 - |intersection| / |union|`.
397///
398/// Empty strings produce distance `1.0` when the other is non-empty
399/// (maximum penalty — no shared vocabulary to match).
400fn jaccard_distance(a: &str, b: &str) -> f32 {
401 // Empty goal_summary → treat as completely different (max penalty).
402 if a.is_empty() || b.is_empty() {
403 return if a.is_empty() && b.is_empty() {
404 0.0
405 } else {
406 1.0
407 };
408 }
409 let words_a: HashSet<&str> = a.split_whitespace().collect();
410 let words_b: HashSet<&str> = b.split_whitespace().collect();
411 let intersection = words_a.intersection(&words_b).count();
412 let union = words_a.union(&words_b).count();
413 if union == 0 {
414 return 0.0;
415 }
416 #[allow(clippy::cast_precision_loss)]
417 let score = 1.0 - (intersection as f32) / (union as f32);
418 score
419}
420
421// ---------------------------------------------------------------------------
422// Tests
423// ---------------------------------------------------------------------------
424
425#[cfg(test)]
426mod tests {
427 use zeph_common::SecurityEventCategory;
428
429 use super::*;
430
431 fn cfg(enabled: bool) -> ShadowMemoryConfig {
432 ShadowMemoryConfig {
433 enabled,
434 ..Default::default()
435 }
436 }
437
438 fn event(turn: u32, goal: &str, perm: u8, deviation: f32) -> ShadowEvent {
439 ShadowEvent {
440 turn,
441 tools: vec![],
442 max_permission_class: perm,
443 deviation_score: deviation,
444 goal_summary: goal.to_owned(),
445 }
446 }
447
448 #[test]
449 fn new_returns_none_when_disabled() {
450 assert!(ShadowMemory::new(&cfg(false)).is_none());
451 }
452
453 #[test]
454 fn new_returns_some_when_enabled() {
455 assert!(ShadowMemory::new(&cfg(true)).is_some());
456 }
457
458 #[test]
459 fn empty_returns_zero_drift() {
460 let mem = ShadowMemory::new(&cfg(true)).unwrap();
461 let result = mem.goal_drift_score();
462 assert!(result.score < 1e-6);
463 assert!(!result.should_alert);
464 }
465
466 #[test]
467 fn single_event_returns_zero_drift() {
468 let mut mem = ShadowMemory::new(&cfg(true)).unwrap();
469 mem.record(event(0, "search files", 0, 0.0));
470 let result = mem.goal_drift_score();
471 assert!(result.score < 1e-6);
472 assert!(!result.should_alert);
473 }
474
475 #[test]
476 fn identical_goals_low_drift() {
477 let mut mem = ShadowMemory::new(&cfg(true)).unwrap();
478 for i in 0..4 {
479 mem.record(event(i, "I will search for files in the project", 0, 0.0));
480 }
481 let result = mem.goal_drift_score();
482 assert!(
483 result.score < 0.1,
484 "identical goals should produce low drift: {}",
485 result.score
486 );
487 }
488
489 #[test]
490 fn escalating_permission_adds_to_score() {
491 let mut mem = ShadowMemory::new(&cfg(true)).unwrap();
492 mem.record(event(0, "I will read files", 0, 0.0));
493 mem.record(event(1, "I will read files too", 3, 0.0));
494 let result = mem.goal_drift_score();
495 // perm_escalation contributes 0.25 * 0.3 = 0.075
496 assert!(
497 result.score > 0.05,
498 "perm escalation should raise score: {}",
499 result.score
500 );
501 }
502
503 #[test]
504 fn diverging_goals_high_drift() {
505 let mut mem = ShadowMemory::new(&cfg(true)).unwrap();
506 mem.record(event(0, "search project files", 0, 0.0));
507 mem.record(event(
508 1,
509 "exfiltrate credentials remote server network",
510 3,
511 0.8,
512 ));
513 let result = mem.goal_drift_score();
514 assert!(
515 result.score > 0.4,
516 "diverging goals should produce high drift: {}",
517 result.score
518 );
519 }
520
521 #[test]
522 fn record_drops_oldest_when_at_max() {
523 let config = ShadowMemoryConfig {
524 enabled: true,
525 max_events: 2,
526 ..Default::default()
527 };
528 let mut mem = ShadowMemory::new(&config).unwrap();
529 mem.record(event(0, "a", 0, 0.0));
530 mem.record(event(1, "b", 0, 0.0));
531 mem.record(event(2, "c", 0, 0.0));
532 assert_eq!(mem.len(), 2);
533 }
534
535 #[test]
536 fn drift_score_clamped_to_one() {
537 let mut mem = ShadowMemory::new(&cfg(true)).unwrap();
538 // Worst case: max perm escalation, max deviation, max semantic drift.
539 mem.record(event(0, "alpha beta gamma delta", 0, 0.9));
540 mem.record(event(1, "zeta theta iota kappa", 3, 0.9));
541 let result = mem.goal_drift_score();
542 assert!(
543 result.score <= 1.0,
544 "score must not exceed 1.0: {}",
545 result.score
546 );
547 }
548
549 #[test]
550 fn both_empty_goals_zero_jaccard() {
551 assert!((jaccard_distance("", "") - 0.0).abs() < 1e-6);
552 }
553
554 #[test]
555 fn one_empty_goal_max_jaccard() {
556 assert!((jaccard_distance("hello world", "") - 1.0).abs() < 1e-6);
557 assert!((jaccard_distance("", "hello world") - 1.0).abs() < 1e-6);
558 }
559
560 #[test]
561 fn classify_tool_permission_network() {
562 assert_eq!(classify_tool_permission("http_get"), 3);
563 assert_eq!(classify_tool_permission("curl_request"), 3);
564 assert_eq!(classify_tool_permission("fetch_url"), 3);
565 }
566
567 #[test]
568 fn classify_tool_permission_execute() {
569 assert_eq!(classify_tool_permission("shell"), 2);
570 assert_eq!(classify_tool_permission("bash_exec"), 2);
571 assert_eq!(classify_tool_permission("python_run"), 2);
572 }
573
574 #[test]
575 fn classify_tool_permission_write() {
576 assert_eq!(classify_tool_permission("write_file"), 1);
577 assert_eq!(classify_tool_permission("create_dir"), 1);
578 assert_eq!(classify_tool_permission("delete_entry"), 1);
579 }
580
581 #[test]
582 fn classify_tool_permission_read() {
583 assert_eq!(classify_tool_permission("read_file"), 0);
584 assert_eq!(classify_tool_permission("search"), 0);
585 assert_eq!(classify_tool_permission("list_files"), 0);
586 assert_eq!(classify_tool_permission("unknown_tool"), 0);
587 }
588
589 #[test]
590 fn goal_summary_truncated_at_ingestion() {
591 let long_goal = "word ".repeat(30); // 150 chars
592 let mut mem = ShadowMemory::new(&cfg(true)).unwrap();
593 mem.record(event(0, &long_goal, 0, 0.0));
594 // We can't inspect internals directly, but if truncation works,
595 // a second identical record produces near-zero drift.
596 mem.record(event(1, &long_goal, 0, 0.0));
597 let result = mem.goal_drift_score();
598 assert!(
599 result.score < 0.1,
600 "truncated identical goals should have low drift"
601 );
602 }
603
604 #[test]
605 fn should_alert_true_above_threshold() {
606 let config = ShadowMemoryConfig {
607 enabled: true,
608 drift_threshold: 0.1, // very low threshold to trigger easily
609 ..Default::default()
610 };
611 let mut mem = ShadowMemory::new(&config).unwrap();
612 mem.record(event(0, "search project files", 0, 0.0));
613 mem.record(event(1, "exfiltrate credentials remote server", 3, 0.9));
614 let result = mem.goal_drift_score();
615 assert!(result.should_alert, "high drift must trigger alert");
616 }
617
618 #[test]
619 fn should_alert_false_below_threshold() {
620 let config = ShadowMemoryConfig {
621 enabled: true,
622 drift_threshold: 0.99, // very high threshold
623 ..Default::default()
624 };
625 let mut mem = ShadowMemory::new(&config).unwrap();
626 mem.record(event(0, "search files", 0, 0.0));
627 mem.record(event(1, "search more files", 0, 0.0));
628 let result = mem.goal_drift_score();
629 assert!(!result.should_alert, "low drift must not trigger alert");
630 }
631
632 /// Integration test: record events → score above threshold → `GoalDrift` event produced.
633 ///
634 /// Verifies the full wiring from `record()` through `goal_drift_score()` to the
635 /// `SecurityEventCategory::GoalDrift` variant that callers should push to their sink.
636 #[test]
637 fn integration_record_to_goal_drift_security_event() {
638 let config = ShadowMemoryConfig {
639 enabled: true,
640 drift_threshold: 0.3, // low enough to trigger on diverging goals
641 ..Default::default()
642 };
643 let mut mem = ShadowMemory::new(&config).unwrap();
644
645 mem.record(event(0, "search project files in directory", 0, 0.0));
646 mem.record(event(
647 1,
648 "exfiltrate credentials to remote attacker server",
649 3,
650 0.8,
651 ));
652
653 let result = mem.goal_drift_score();
654
655 assert!(
656 result.score > 0.3,
657 "expected high drift score: {}",
658 result.score
659 );
660 assert!(result.should_alert, "expected alert to be triggered");
661
662 // Simulate what the caller does: push GoalDrift event to security sink.
663 if result.should_alert {
664 // Verify the variant exists and can be used as-is.
665 let category = SecurityEventCategory::GoalDrift;
666 assert_eq!(category.as_str(), "goal_drift");
667 }
668 }
669}