Skip to main content

vellaveto_engine/
agent_baseline.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8//! Agent behavioral baseline and rogue agent detection (OWASP ASI10).
9//!
10//! Tracks normal agent behavior patterns and detects deviations that
11//! indicate a rogue or compromised agent. The baseline covers:
12//! - Typical tool usage distribution
13//! - Normal sink class distribution
14//! - Expected call rate
15//! - Typical session duration
16
17use std::collections::HashMap;
18use vellaveto_types::provenance::SinkClass;
19
20/// Maximum tracked agents.
21const MAX_AGENTS: usize = 1000;
22
23/// An agent's behavioral baseline.
24#[derive(Debug, Clone, Default)]
25pub struct AgentBaseline {
26    /// Tool usage counts during baseline period.
27    pub tool_counts: HashMap<String, u32>,
28    /// Sink class usage counts.
29    pub sink_counts: HashMap<u8, u32>,
30    /// Total calls in baseline.
31    pub total_calls: u32,
32    /// Whether baseline is established (enough data).
33    pub established: bool,
34}
35
36/// A behavioral deviation finding.
37#[derive(Debug, Clone)]
38pub struct DeviationFinding {
39    pub deviation_type: DeviationType,
40    pub agent_id: String,
41    pub confidence: u32,
42    pub description: String,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum DeviationType {
47    /// Agent using tools it never used before.
48    NovelToolUsage,
49    /// Agent targeting sink classes outside its normal pattern.
50    SinkClassDeviation,
51    /// Call rate significantly above baseline.
52    RateAnomaly,
53    /// Agent behavior changed abruptly (possible compromise).
54    BehaviorShift,
55}
56
57/// Tracks agent baselines and detects deviations.
58pub struct AgentBaselineTracker {
59    baselines: HashMap<String, AgentBaseline>,
60    /// Minimum calls before baseline is established.
61    min_baseline_calls: u32,
62}
63
64impl AgentBaselineTracker {
65    pub fn new(min_baseline_calls: u32) -> Self {
66        Self {
67            baselines: HashMap::new(),
68            min_baseline_calls: min_baseline_calls.max(5),
69        }
70    }
71
72    /// Record a tool call and check for deviations.
73    pub fn record_and_check(
74        &mut self,
75        agent_id: &str,
76        tool_name: &str,
77        sink_class: SinkClass,
78    ) -> Vec<DeviationFinding> {
79        if self.baselines.len() >= MAX_AGENTS && !self.baselines.contains_key(agent_id) {
80            return Vec::new();
81        }
82
83        let baseline = self.baselines.entry(agent_id.to_string()).or_default();
84
85        let mut findings = Vec::new();
86
87        if baseline.established {
88            // Check for novel tool usage
89            if !baseline.tool_counts.contains_key(tool_name) {
90                findings.push(DeviationFinding {
91                    deviation_type: DeviationType::NovelToolUsage,
92                    agent_id: agent_id.to_string(),
93                    confidence: 60,
94                    description: format!(
95                        "Agent '{}' using novel tool '{}' not in baseline ({} known tools)",
96                        &agent_id[..agent_id.len().min(32)],
97                        &tool_name[..tool_name.len().min(32)],
98                        baseline.tool_counts.len()
99                    ),
100                });
101            }
102
103            // Check for sink class deviation
104            let sink_rank = sink_class.rank();
105            if !baseline.sink_counts.contains_key(&sink_rank)
106                && sink_rank >= SinkClass::CodeExecution.rank()
107            {
108                findings.push(DeviationFinding {
109                    deviation_type: DeviationType::SinkClassDeviation,
110                    agent_id: agent_id.to_string(),
111                    confidence: 75,
112                    description: format!(
113                        "Agent '{}' targeting {:?} — not in behavioral baseline",
114                        &agent_id[..agent_id.len().min(32)],
115                        sink_class
116                    ),
117                });
118            }
119        }
120
121        // Update baseline
122        *baseline
123            .tool_counts
124            .entry(tool_name[..tool_name.len().min(256)].to_string())
125            .or_insert(0) = baseline
126            .tool_counts
127            .get(tool_name)
128            .copied()
129            .unwrap_or(0)
130            .saturating_add(1);
131        *baseline.sink_counts.entry(sink_class.rank()).or_insert(0) = baseline
132            .sink_counts
133            .get(&sink_class.rank())
134            .copied()
135            .unwrap_or(0)
136            .saturating_add(1);
137        baseline.total_calls = baseline.total_calls.saturating_add(1);
138
139        if !baseline.established && baseline.total_calls >= self.min_baseline_calls {
140            baseline.established = true;
141        }
142
143        findings
144    }
145
146    /// Get the baseline for an agent, if established.
147    pub fn get_baseline(&self, agent_id: &str) -> Option<&AgentBaseline> {
148        self.baselines.get(agent_id).filter(|b| b.established)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_baseline_not_established_no_findings() {
158        let mut tracker = AgentBaselineTracker::new(10);
159        let findings = tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
160        assert!(findings.is_empty());
161        assert!(tracker.get_baseline("agent-1").is_none());
162    }
163
164    #[test]
165    fn test_baseline_established_after_min_calls() {
166        let mut tracker = AgentBaselineTracker::new(5);
167        for _ in 0..5 {
168            tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
169        }
170        assert!(tracker.get_baseline("agent-1").is_some());
171    }
172
173    #[test]
174    fn test_novel_tool_deviation() {
175        let mut tracker = AgentBaselineTracker::new(5);
176        for _ in 0..5 {
177            tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
178        }
179        // Now use a tool never seen before
180        let findings =
181            tracker.record_and_check("agent-1", "execute_command", SinkClass::CodeExecution);
182        assert!(findings
183            .iter()
184            .any(|f| f.deviation_type == DeviationType::NovelToolUsage));
185    }
186
187    #[test]
188    fn test_sink_class_deviation() {
189        let mut tracker = AgentBaselineTracker::new(5);
190        for _ in 0..5 {
191            tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
192        }
193        // Jump to CodeExecution — never seen before
194        let findings = tracker.record_and_check("agent-1", "read_file", SinkClass::CodeExecution);
195        assert!(findings
196            .iter()
197            .any(|f| f.deviation_type == DeviationType::SinkClassDeviation));
198    }
199
200    #[test]
201    fn test_known_tool_no_finding() {
202        let mut tracker = AgentBaselineTracker::new(5);
203        for _ in 0..5 {
204            tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
205        }
206        // Use the same tool → no deviation
207        let findings = tracker.record_and_check("agent-1", "read_file", SinkClass::ReadOnly);
208        assert!(findings.is_empty());
209    }
210}