Skip to main content

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}