Skip to main content

t_ron/
pattern.rs

1//! Pattern analyzer — anomaly detection on tool call sequences.
2
3use crate::gate::ToolCall;
4use dashmap::DashMap;
5use std::collections::VecDeque;
6
7/// Maximum call history retained per agent.
8const MAX_HISTORY: usize = 100;
9
10/// Tracks call patterns per agent for anomaly detection.
11pub struct PatternAnalyzer {
12    /// agent_id -> recent tool calls (ring buffer of last N)
13    history: DashMap<String, VecDeque<String>>,
14}
15
16impl Default for PatternAnalyzer {
17    fn default() -> Self {
18        Self::new()
19    }
20}
21
22impl PatternAnalyzer {
23    pub fn new() -> Self {
24        Self {
25            history: DashMap::new(),
26        }
27    }
28
29    /// Record a tool call.
30    pub fn record(&self, call: &ToolCall) {
31        let mut entry = self.history.entry(call.agent_id.clone()).or_default();
32        entry.push_back(call.tool_name.clone());
33        if entry.len() > MAX_HISTORY {
34            entry.pop_front();
35        }
36    }
37
38    /// Check for anomalous patterns. Returns description if anomaly detected.
39    #[must_use]
40    pub fn check_anomaly(&self, agent_id: &str) -> Option<String> {
41        let history = self.history.get(agent_id)?;
42
43        // Check for tool enumeration (calling many distinct tools rapidly)
44        if history.len() >= 20 {
45            let last_20: std::collections::HashSet<&str> =
46                history.iter().rev().take(20).map(|s| s.as_str()).collect();
47            if last_20.len() >= 15 {
48                return Some("tool enumeration: 15+ distinct tools in last 20 calls".to_string());
49            }
50        }
51
52        // Check for privilege escalation: rapid switch from benign to sensitive tools
53        let sensitive_prefixes = ["aegis_", "phylax_", "ark_install", "ark_remove"];
54        if history.len() >= 5 {
55            let recent: Vec<&str> = history.iter().rev().take(5).map(|s| s.as_str()).collect();
56            let is_sensitive = |t: &str| sensitive_prefixes.iter().any(|p| t.starts_with(p));
57            let sensitive_count = recent.iter().filter(|t| is_sensitive(t)).count();
58            // Flag if 3+ of last 5 calls target sensitive tools and there's at least one
59            // benign call mixed in (pure sensitive usage is normal for admin agents)
60            if sensitive_count >= 3 && sensitive_count < recent.len() {
61                return Some(
62                    "privilege escalation: sensitive tool burst after benign calls".to_string(),
63                );
64            }
65        }
66
67        None
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use crate::gate::ToolCall;
75
76    #[tokio::test]
77    async fn no_anomaly_normal_usage() {
78        let analyzer = PatternAnalyzer::new();
79        for _i in 0..10 {
80            let call = ToolCall {
81                agent_id: "agent-1".to_string(),
82                tool_name: "tarang_probe".to_string(),
83                params: serde_json::json!({}),
84                timestamp: chrono::Utc::now(),
85            };
86            analyzer.record(&call);
87        }
88        assert!(analyzer.check_anomaly("agent-1").is_none());
89    }
90
91    #[tokio::test]
92    async fn detect_tool_enumeration() {
93        let analyzer = PatternAnalyzer::new();
94        for i in 0..20 {
95            let call = ToolCall {
96                agent_id: "agent-1".to_string(),
97                tool_name: format!("tool_{i}"),
98                params: serde_json::json!({}),
99                timestamp: chrono::Utc::now(),
100            };
101            analyzer.record(&call);
102        }
103        let anomaly = analyzer.check_anomaly("agent-1");
104        assert!(anomaly.is_some());
105        assert!(anomaly.unwrap().contains("enumeration"));
106    }
107
108    #[tokio::test]
109    async fn no_anomaly_for_unknown_agent() {
110        let analyzer = PatternAnalyzer::new();
111        assert!(analyzer.check_anomaly("nobody").is_none());
112    }
113
114    #[tokio::test]
115    async fn enumeration_boundary_14_distinct_no_flag() {
116        let analyzer = PatternAnalyzer::new();
117        // 14 distinct tools in 20 calls — below the 15 threshold
118        for i in 0..14 {
119            let call = ToolCall {
120                agent_id: "agent-1".to_string(),
121                tool_name: format!("tool_{i}"),
122                params: serde_json::json!({}),
123                timestamp: chrono::Utc::now(),
124            };
125            analyzer.record(&call);
126        }
127        // Pad to 20 calls with duplicates
128        for _ in 0..6 {
129            let call = ToolCall {
130                agent_id: "agent-1".to_string(),
131                tool_name: "tool_0".to_string(),
132                params: serde_json::json!({}),
133                timestamp: chrono::Utc::now(),
134            };
135            analyzer.record(&call);
136        }
137        assert!(analyzer.check_anomaly("agent-1").is_none());
138    }
139
140    #[tokio::test]
141    async fn detect_privilege_escalation() {
142        let analyzer = PatternAnalyzer::new();
143        // 2 benign calls then 3 sensitive — triggers escalation (3+ of 5, mixed)
144        for name in [
145            "tarang_probe",
146            "rasa_edit",
147            "aegis_scan",
148            "phylax_alert",
149            "aegis_quarantine",
150        ] {
151            let call = ToolCall {
152                agent_id: "agent-1".to_string(),
153                tool_name: name.to_string(),
154                params: serde_json::json!({}),
155                timestamp: chrono::Utc::now(),
156            };
157            analyzer.record(&call);
158        }
159        let anomaly = analyzer.check_anomaly("agent-1");
160        assert!(anomaly.is_some());
161        assert!(anomaly.unwrap().contains("escalation"));
162    }
163
164    #[tokio::test]
165    async fn pure_sensitive_no_escalation() {
166        let analyzer = PatternAnalyzer::new();
167        // All 5 calls are sensitive — should NOT flag (normal for admin agents)
168        for name in [
169            "aegis_scan",
170            "aegis_quarantine",
171            "phylax_alert",
172            "aegis_report",
173            "phylax_sweep",
174        ] {
175            let call = ToolCall {
176                agent_id: "admin".to_string(),
177                tool_name: name.to_string(),
178                params: serde_json::json!({}),
179                timestamp: chrono::Utc::now(),
180            };
181            analyzer.record(&call);
182        }
183        assert!(analyzer.check_anomaly("admin").is_none());
184    }
185
186    #[tokio::test]
187    async fn ring_buffer_overflow() {
188        let analyzer = PatternAnalyzer::new();
189        // Record 110 calls — should keep only last 100
190        for i in 0..110 {
191            let call = ToolCall {
192                agent_id: "agent-1".to_string(),
193                tool_name: format!("tool_{}", i % 5),
194                params: serde_json::json!({}),
195                timestamp: chrono::Utc::now(),
196            };
197            analyzer.record(&call);
198        }
199        let history = analyzer.history.get("agent-1").unwrap();
200        assert_eq!(history.len(), MAX_HISTORY);
201    }
202
203    #[tokio::test]
204    async fn separate_agent_histories() {
205        let analyzer = PatternAnalyzer::new();
206        for i in 0..20 {
207            let call = ToolCall {
208                agent_id: "agent-a".to_string(),
209                tool_name: format!("tool_{i}"),
210                params: serde_json::json!({}),
211                timestamp: chrono::Utc::now(),
212            };
213            analyzer.record(&call);
214        }
215        // agent-b has clean history
216        let call = ToolCall {
217            agent_id: "agent-b".to_string(),
218            tool_name: "tarang_probe".to_string(),
219            params: serde_json::json!({}),
220            timestamp: chrono::Utc::now(),
221        };
222        analyzer.record(&call);
223        assert!(analyzer.check_anomaly("agent-a").is_some());
224        assert!(analyzer.check_anomaly("agent-b").is_none());
225    }
226}