1use crate::gate::ToolCall;
4use dashmap::DashMap;
5use std::collections::VecDeque;
6
7const MAX_HISTORY: usize = 100;
9
10pub struct PatternAnalyzer {
12 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 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 #[must_use]
40 pub fn check_anomaly(&self, agent_id: &str) -> Option<String> {
41 let history = self.history.get(agent_id)?;
42
43 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 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 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 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 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 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 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 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 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}