Skip to main content

arbiter_session/
model.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Unique identifier for a task session.
6pub type SessionId = Uuid;
7
8/// Maximum data sensitivity level allowed in this session.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum DataSensitivity {
12    Public,
13    Internal,
14    Confidential,
15    Restricted,
16}
17
18fn default_rate_limit_window_secs() -> u64 {
19    60
20}
21
22/// The status of a task session.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum SessionStatus {
26    Active,
27    Closed,
28    Expired,
29}
30
31/// A task session scoping what an agent is allowed to do.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TaskSession {
34    /// Unique session identifier.
35    pub session_id: SessionId,
36
37    /// The agent operating within this session.
38    pub agent_id: Uuid,
39
40    /// Snapshot of the delegation chain at session creation time.
41    pub delegation_chain_snapshot: Vec<String>,
42
43    /// The declared intent for this session (free-form string).
44    pub declared_intent: String,
45
46    /// Tools this session is authorized to call (from policy evaluation).
47    pub authorized_tools: Vec<String>,
48
49    /// Maximum duration for this session.
50    pub time_limit: chrono::Duration,
51
52    /// Maximum number of tool calls allowed.
53    pub call_budget: u64,
54
55    /// Number of tool calls made so far.
56    pub calls_made: u64,
57
58    /// Per-minute rate limit. `None` means no rate limit (only lifetime budget applies).
59    #[serde(default)]
60    pub rate_limit_per_minute: Option<u64>,
61
62    /// Start of the current rate-limit window.
63    #[serde(default = "Utc::now")]
64    pub rate_window_start: DateTime<Utc>,
65
66    /// Number of calls within the current rate-limit window.
67    #[serde(default)]
68    pub rate_window_calls: u64,
69
70    /// Duration of the rate-limit window in seconds. Defaults to 60.
71    #[serde(default = "default_rate_limit_window_secs")]
72    pub rate_limit_window_secs: u64,
73
74    /// Maximum data sensitivity this session may access.
75    pub data_sensitivity_ceiling: DataSensitivity,
76
77    /// When this session was created.
78    pub created_at: DateTime<Utc>,
79
80    /// Current session status.
81    pub status: SessionStatus,
82}
83
84impl TaskSession {
85    /// Returns true if the session has exceeded its time limit.
86    pub fn is_expired(&self) -> bool {
87        let elapsed = Utc::now() - self.created_at;
88        elapsed > self.time_limit || self.status == SessionStatus::Expired
89    }
90
91    /// Returns true if the session's call budget is exhausted.
92    pub fn is_budget_exceeded(&self) -> bool {
93        self.calls_made >= self.call_budget
94    }
95
96    /// Returns true if the given tool is authorized in this session.
97    pub fn is_tool_authorized(&self, tool_name: &str) -> bool {
98        // Empty authorized_tools means "all tools allowed" (wide-open session).
99        self.authorized_tools.is_empty() || self.authorized_tools.iter().any(|t| t == tool_name)
100    }
101
102    /// Returns true if the session is active and usable.
103    pub fn is_active(&self) -> bool {
104        self.status == SessionStatus::Active && !self.is_expired() && !self.is_budget_exceeded()
105    }
106
107    /// Check and update the rate-limit window. Returns true if the call
108    /// should be rejected due to rate limiting.
109    pub fn check_rate_limit(&mut self) -> bool {
110        let limit = match self.rate_limit_per_minute {
111            Some(l) => l,
112            None => return false,
113        };
114        let now = Utc::now();
115        let elapsed = now - self.rate_window_start;
116        if elapsed >= chrono::Duration::seconds(self.rate_limit_window_secs as i64) {
117            // New window. Reset.
118            self.rate_window_start = now;
119            self.rate_window_calls = 1;
120            false
121        } else if self.rate_window_calls >= limit {
122            true // rate limited
123        } else {
124            self.rate_window_calls += 1;
125            false
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    fn test_session() -> TaskSession {
135        TaskSession {
136            session_id: Uuid::new_v4(),
137            agent_id: Uuid::new_v4(),
138            delegation_chain_snapshot: vec![],
139            declared_intent: "read files".into(),
140            authorized_tools: vec!["read_file".into(), "list_dir".into()],
141            time_limit: chrono::Duration::hours(1),
142            call_budget: 100,
143            calls_made: 0,
144            rate_limit_per_minute: None,
145            rate_window_start: Utc::now(),
146            rate_window_calls: 0,
147            rate_limit_window_secs: 60,
148            data_sensitivity_ceiling: DataSensitivity::Internal,
149            created_at: Utc::now(),
150            status: SessionStatus::Active,
151        }
152    }
153
154    #[test]
155    fn active_session_is_usable() {
156        let session = test_session();
157        assert!(session.is_active());
158        assert!(!session.is_expired());
159        assert!(!session.is_budget_exceeded());
160    }
161
162    #[test]
163    fn tool_authorization_check() {
164        let session = test_session();
165        assert!(session.is_tool_authorized("read_file"));
166        assert!(session.is_tool_authorized("list_dir"));
167        assert!(!session.is_tool_authorized("delete_file"));
168    }
169
170    #[test]
171    fn budget_exhaustion() {
172        let mut session = test_session();
173        session.calls_made = 100;
174        assert!(session.is_budget_exceeded());
175        assert!(!session.is_active());
176    }
177
178    #[test]
179    fn expired_session() {
180        let mut session = test_session();
181        session.created_at = Utc::now() - chrono::Duration::hours(2);
182        assert!(session.is_expired());
183        assert!(!session.is_active());
184    }
185
186    #[test]
187    fn rate_limit_none_always_allows() {
188        let mut session = test_session();
189        assert_eq!(session.rate_limit_per_minute, None);
190        for _ in 0..1000 {
191            assert!(
192                !session.check_rate_limit(),
193                "None rate limit must never deny"
194            );
195        }
196        // With no rate limit configured, window calls should stay at zero
197        // because the method returns early before touching the counter.
198        assert_eq!(session.rate_window_calls, 0);
199    }
200
201    #[test]
202    fn rate_limit_under_threshold_allows() {
203        let mut session = test_session();
204        session.rate_limit_per_minute = Some(5);
205        for i in 0..4 {
206            assert!(
207                !session.check_rate_limit(),
208                "Call {} should be allowed under threshold of 5",
209                i + 1
210            );
211        }
212        assert_eq!(session.rate_window_calls, 4);
213    }
214
215    #[test]
216    fn rate_limit_at_threshold_denies() {
217        let mut session = test_session();
218        session.rate_limit_per_minute = Some(3);
219        // Simulate that the window already has 3 calls recorded.
220        session.rate_window_calls = 3;
221        assert!(
222            session.check_rate_limit(),
223            "Must deny when calls already at limit"
224        );
225    }
226
227    #[test]
228    fn rate_limit_window_reset() {
229        let mut session = test_session();
230        session.rate_limit_per_minute = Some(5);
231        // Push the window start 61 seconds into the past so the window is expired.
232        session.rate_window_start = Utc::now() - chrono::Duration::seconds(61);
233        session.rate_window_calls = 5; // was at limit in the old window
234
235        let denied = session.check_rate_limit();
236        assert!(!denied, "New window should allow the call");
237        assert_eq!(
238            session.rate_window_calls, 1,
239            "Window must reset to 1 after a new window starts"
240        );
241    }
242
243    #[test]
244    fn empty_authorized_tools_allows_all() {
245        let mut session = test_session();
246        session.authorized_tools = vec![];
247        assert!(
248            session.is_tool_authorized("anything_goes"),
249            "Empty authorized_tools must allow any tool"
250        );
251        assert!(
252            session.is_tool_authorized("delete_file"),
253            "Empty authorized_tools must allow any tool"
254        );
255        assert!(
256            session.is_tool_authorized(""),
257            "Empty authorized_tools must allow even empty-string tool name"
258        );
259    }
260
261    #[test]
262    fn closed_session_not_active() {
263        let mut session = test_session();
264        session.status = SessionStatus::Closed;
265        assert!(
266            !session.is_active(),
267            "Closed session must not be considered active"
268        );
269        // Confirm it is NOT because of expiry or budget.
270        assert!(!session.is_expired());
271        assert!(!session.is_budget_exceeded());
272    }
273
274    #[test]
275    fn budget_boundary_at_limit_minus_one() {
276        let mut session = test_session();
277        session.calls_made = session.call_budget - 1;
278        assert!(
279            !session.is_budget_exceeded(),
280            "One call below budget must not be exceeded"
281        );
282        assert!(
283            session.is_active(),
284            "Session at budget - 1 should still be active"
285        );
286    }
287
288    /// A session with call_budget=0 must report budget exceeded immediately.
289    #[test]
290    fn zero_budget_is_exceeded() {
291        let mut session = test_session();
292        session.call_budget = 0;
293        session.calls_made = 0;
294        assert!(
295            session.is_budget_exceeded(),
296            "0 >= 0 means budget is exceeded"
297        );
298        assert!(
299            !session.is_active(),
300            "zero-budget session should not be active"
301        );
302    }
303
304    /// When elapsed == window duration exactly, the window should reset.
305    #[test]
306    fn check_rate_limit_at_exact_window_boundary() {
307        let mut session = test_session();
308        session.rate_limit_per_minute = Some(5);
309        // Set the window start exactly `rate_limit_window_secs` ago.
310        session.rate_window_start =
311            Utc::now() - chrono::Duration::seconds(session.rate_limit_window_secs as i64);
312        session.rate_window_calls = 5; // was at limit in old window
313
314        let denied = session.check_rate_limit();
315        assert!(!denied, "exact window boundary should reset and allow");
316        assert_eq!(
317            session.rate_window_calls, 1,
318            "window must reset to 1 after boundary reset"
319        );
320    }
321}