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    pub fn check_anomaly(&self, agent_id: &str) -> Option<String> {
40        let history = self.history.get(agent_id)?;
41
42        // Check for tool enumeration (calling many distinct tools rapidly)
43        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        // Check for privilege escalation: rapid switch from benign to sensitive tools
52        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            // Flag if 3+ of last 5 calls target sensitive tools and there's at least one
58            // benign call mixed in (pure sensitive usage is normal for admin agents)
59            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        // 14 distinct tools in 20 calls — below the 15 threshold
117        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        // Pad to 20 calls with duplicates
127        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        // 2 benign calls then 3 sensitive — triggers escalation (3+ of 5, mixed)
143        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        // All 5 calls are sensitive — should NOT flag (normal for admin agents)
167        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        // Record 110 calls — should keep only last 100
189        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        // agent-b has clean history
215        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}