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 pub fn check_anomaly(&self, agent_id: &str) -> Option<String> {
40 let history = self.history.get(agent_id)?;
41
42 if history.len() >= 20 {
44 let last_20: std::collections::HashSet<&str> =
45 history.iter().rev().take(20).map(|s| s.as_str()).collect();
46 if last_20.len() >= 15 {
47 return Some("tool enumeration: 15+ distinct tools in last 20 calls".to_string());
48 }
49 }
50
51 let sensitive_prefixes = ["aegis_", "phylax_", "ark_install", "ark_remove"];
53 if history.len() >= 5 {
54 let recent: Vec<&str> = history.iter().rev().take(5).map(|s| s.as_str()).collect();
55 let is_sensitive = |t: &str| sensitive_prefixes.iter().any(|p| t.starts_with(p));
56 let sensitive_count = recent.iter().filter(|t| is_sensitive(t)).count();
57 if sensitive_count >= 3 && sensitive_count < recent.len() {
60 return Some(
61 "privilege escalation: sensitive tool burst after benign calls".to_string(),
62 );
63 }
64 }
65
66 None
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73 use crate::gate::ToolCall;
74
75 #[tokio::test]
76 async fn no_anomaly_normal_usage() {
77 let analyzer = PatternAnalyzer::new();
78 for _i in 0..10 {
79 let call = ToolCall {
80 agent_id: "agent-1".to_string(),
81 tool_name: "tarang_probe".to_string(),
82 params: serde_json::json!({}),
83 timestamp: chrono::Utc::now(),
84 };
85 analyzer.record(&call);
86 }
87 assert!(analyzer.check_anomaly("agent-1").is_none());
88 }
89
90 #[tokio::test]
91 async fn detect_tool_enumeration() {
92 let analyzer = PatternAnalyzer::new();
93 for i in 0..20 {
94 let call = ToolCall {
95 agent_id: "agent-1".to_string(),
96 tool_name: format!("tool_{i}"),
97 params: serde_json::json!({}),
98 timestamp: chrono::Utc::now(),
99 };
100 analyzer.record(&call);
101 }
102 let anomaly = analyzer.check_anomaly("agent-1");
103 assert!(anomaly.is_some());
104 assert!(anomaly.unwrap().contains("enumeration"));
105 }
106
107 #[tokio::test]
108 async fn no_anomaly_for_unknown_agent() {
109 let analyzer = PatternAnalyzer::new();
110 assert!(analyzer.check_anomaly("nobody").is_none());
111 }
112
113 #[tokio::test]
114 async fn enumeration_boundary_14_distinct_no_flag() {
115 let analyzer = PatternAnalyzer::new();
116 for i in 0..14 {
118 let call = ToolCall {
119 agent_id: "agent-1".to_string(),
120 tool_name: format!("tool_{i}"),
121 params: serde_json::json!({}),
122 timestamp: chrono::Utc::now(),
123 };
124 analyzer.record(&call);
125 }
126 for _ in 0..6 {
128 let call = ToolCall {
129 agent_id: "agent-1".to_string(),
130 tool_name: "tool_0".to_string(),
131 params: serde_json::json!({}),
132 timestamp: chrono::Utc::now(),
133 };
134 analyzer.record(&call);
135 }
136 assert!(analyzer.check_anomaly("agent-1").is_none());
137 }
138
139 #[tokio::test]
140 async fn detect_privilege_escalation() {
141 let analyzer = PatternAnalyzer::new();
142 for name in [
144 "tarang_probe",
145 "rasa_edit",
146 "aegis_scan",
147 "phylax_alert",
148 "aegis_quarantine",
149 ] {
150 let call = ToolCall {
151 agent_id: "agent-1".to_string(),
152 tool_name: name.to_string(),
153 params: serde_json::json!({}),
154 timestamp: chrono::Utc::now(),
155 };
156 analyzer.record(&call);
157 }
158 let anomaly = analyzer.check_anomaly("agent-1");
159 assert!(anomaly.is_some());
160 assert!(anomaly.unwrap().contains("escalation"));
161 }
162
163 #[tokio::test]
164 async fn pure_sensitive_no_escalation() {
165 let analyzer = PatternAnalyzer::new();
166 for name in [
168 "aegis_scan",
169 "aegis_quarantine",
170 "phylax_alert",
171 "aegis_report",
172 "phylax_sweep",
173 ] {
174 let call = ToolCall {
175 agent_id: "admin".to_string(),
176 tool_name: name.to_string(),
177 params: serde_json::json!({}),
178 timestamp: chrono::Utc::now(),
179 };
180 analyzer.record(&call);
181 }
182 assert!(analyzer.check_anomaly("admin").is_none());
183 }
184
185 #[tokio::test]
186 async fn ring_buffer_overflow() {
187 let analyzer = PatternAnalyzer::new();
188 for i in 0..110 {
190 let call = ToolCall {
191 agent_id: "agent-1".to_string(),
192 tool_name: format!("tool_{}", i % 5),
193 params: serde_json::json!({}),
194 timestamp: chrono::Utc::now(),
195 };
196 analyzer.record(&call);
197 }
198 let history = analyzer.history.get("agent-1").unwrap();
199 assert_eq!(history.len(), MAX_HISTORY);
200 }
201
202 #[tokio::test]
203 async fn separate_agent_histories() {
204 let analyzer = PatternAnalyzer::new();
205 for i in 0..20 {
206 let call = ToolCall {
207 agent_id: "agent-a".to_string(),
208 tool_name: format!("tool_{i}"),
209 params: serde_json::json!({}),
210 timestamp: chrono::Utc::now(),
211 };
212 analyzer.record(&call);
213 }
214 let call = ToolCall {
216 agent_id: "agent-b".to_string(),
217 tool_name: "tarang_probe".to_string(),
218 params: serde_json::json!({}),
219 timestamp: chrono::Utc::now(),
220 };
221 analyzer.record(&call);
222 assert!(analyzer.check_anomaly("agent-a").is_some());
223 assert!(analyzer.check_anomaly("agent-b").is_none());
224 }
225}