1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5pub type SessionId = Uuid;
7
8#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct TaskSession {
34 pub session_id: SessionId,
36
37 pub agent_id: Uuid,
39
40 pub delegation_chain_snapshot: Vec<String>,
42
43 pub declared_intent: String,
45
46 pub authorized_tools: Vec<String>,
48
49 #[serde(default)]
55 pub authorized_credentials: Vec<String>,
56
57 pub time_limit: chrono::Duration,
59
60 pub call_budget: u64,
62
63 pub calls_made: u64,
65
66 #[serde(default)]
68 pub rate_limit_per_minute: Option<u64>,
69
70 #[serde(default = "Utc::now")]
72 pub rate_window_start: DateTime<Utc>,
73
74 #[serde(default)]
76 pub rate_window_calls: u64,
77
78 #[serde(default = "default_rate_limit_window_secs")]
80 pub rate_limit_window_secs: u64,
81
82 pub data_sensitivity_ceiling: DataSensitivity,
84
85 pub created_at: DateTime<Utc>,
87
88 pub status: SessionStatus,
90}
91
92impl TaskSession {
93 pub fn is_expired(&self) -> bool {
95 let elapsed = Utc::now() - self.created_at;
96 elapsed > self.time_limit || self.status == SessionStatus::Expired
97 }
98
99 pub fn is_budget_exceeded(&self) -> bool {
101 self.calls_made >= self.call_budget
102 }
103
104 pub fn is_tool_authorized(&self, tool_name: &str) -> bool {
106 self.authorized_tools.is_empty() || self.authorized_tools.iter().any(|t| t == tool_name)
108 }
109
110 pub fn is_credential_authorized(&self, reference: &str) -> bool {
115 self.authorized_credentials.is_empty()
116 || self.authorized_credentials.iter().any(|c| c == reference)
117 }
118
119 pub fn is_active(&self) -> bool {
121 self.status == SessionStatus::Active && !self.is_expired() && !self.is_budget_exceeded()
122 }
123
124 pub fn check_rate_limit(&mut self) -> bool {
127 let limit = match self.rate_limit_per_minute {
128 Some(l) => l,
129 None => return false,
130 };
131 let now = Utc::now();
132 let elapsed = now - self.rate_window_start;
133 if elapsed >= chrono::Duration::seconds(self.rate_limit_window_secs as i64) {
134 self.rate_window_start = now;
136 self.rate_window_calls = 1;
137 false
138 } else if self.rate_window_calls >= limit {
139 true } else {
141 self.rate_window_calls += 1;
142 false
143 }
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 fn test_session() -> TaskSession {
152 TaskSession {
153 session_id: Uuid::new_v4(),
154 agent_id: Uuid::new_v4(),
155 delegation_chain_snapshot: vec![],
156 declared_intent: "read files".into(),
157 authorized_tools: vec!["read_file".into(), "list_dir".into()],
158 authorized_credentials: vec![],
159 time_limit: chrono::Duration::hours(1),
160 call_budget: 100,
161 calls_made: 0,
162 rate_limit_per_minute: None,
163 rate_window_start: Utc::now(),
164 rate_window_calls: 0,
165 rate_limit_window_secs: 60,
166 data_sensitivity_ceiling: DataSensitivity::Internal,
167 created_at: Utc::now(),
168 status: SessionStatus::Active,
169 }
170 }
171
172 #[test]
173 fn active_session_is_usable() {
174 let session = test_session();
175 assert!(session.is_active());
176 assert!(!session.is_expired());
177 assert!(!session.is_budget_exceeded());
178 }
179
180 #[test]
181 fn tool_authorization_check() {
182 let session = test_session();
183 assert!(session.is_tool_authorized("read_file"));
184 assert!(session.is_tool_authorized("list_dir"));
185 assert!(!session.is_tool_authorized("delete_file"));
186 }
187
188 #[test]
189 fn budget_exhaustion() {
190 let mut session = test_session();
191 session.calls_made = 100;
192 assert!(session.is_budget_exceeded());
193 assert!(!session.is_active());
194 }
195
196 #[test]
197 fn expired_session() {
198 let mut session = test_session();
199 session.created_at = Utc::now() - chrono::Duration::hours(2);
200 assert!(session.is_expired());
201 assert!(!session.is_active());
202 }
203
204 #[test]
205 fn rate_limit_none_always_allows() {
206 let mut session = test_session();
207 assert_eq!(session.rate_limit_per_minute, None);
208 for _ in 0..1000 {
209 assert!(
210 !session.check_rate_limit(),
211 "None rate limit must never deny"
212 );
213 }
214 assert_eq!(session.rate_window_calls, 0);
217 }
218
219 #[test]
220 fn rate_limit_under_threshold_allows() {
221 let mut session = test_session();
222 session.rate_limit_per_minute = Some(5);
223 for i in 0..4 {
224 assert!(
225 !session.check_rate_limit(),
226 "Call {} should be allowed under threshold of 5",
227 i + 1
228 );
229 }
230 assert_eq!(session.rate_window_calls, 4);
231 }
232
233 #[test]
234 fn rate_limit_at_threshold_denies() {
235 let mut session = test_session();
236 session.rate_limit_per_minute = Some(3);
237 session.rate_window_calls = 3;
239 assert!(
240 session.check_rate_limit(),
241 "Must deny when calls already at limit"
242 );
243 }
244
245 #[test]
246 fn rate_limit_window_reset() {
247 let mut session = test_session();
248 session.rate_limit_per_minute = Some(5);
249 session.rate_window_start = Utc::now() - chrono::Duration::seconds(61);
251 session.rate_window_calls = 5; let denied = session.check_rate_limit();
254 assert!(!denied, "New window should allow the call");
255 assert_eq!(
256 session.rate_window_calls, 1,
257 "Window must reset to 1 after a new window starts"
258 );
259 }
260
261 #[test]
262 fn empty_authorized_tools_allows_all() {
263 let mut session = test_session();
264 session.authorized_tools = vec![];
265 assert!(
266 session.is_tool_authorized("anything_goes"),
267 "Empty authorized_tools must allow any tool"
268 );
269 assert!(
270 session.is_tool_authorized("delete_file"),
271 "Empty authorized_tools must allow any tool"
272 );
273 assert!(
274 session.is_tool_authorized(""),
275 "Empty authorized_tools must allow even empty-string tool name"
276 );
277 }
278
279 #[test]
280 fn closed_session_not_active() {
281 let mut session = test_session();
282 session.status = SessionStatus::Closed;
283 assert!(
284 !session.is_active(),
285 "Closed session must not be considered active"
286 );
287 assert!(!session.is_expired());
289 assert!(!session.is_budget_exceeded());
290 }
291
292 #[test]
293 fn budget_boundary_at_limit_minus_one() {
294 let mut session = test_session();
295 session.calls_made = session.call_budget - 1;
296 assert!(
297 !session.is_budget_exceeded(),
298 "One call below budget must not be exceeded"
299 );
300 assert!(
301 session.is_active(),
302 "Session at budget - 1 should still be active"
303 );
304 }
305
306 #[test]
308 fn zero_budget_is_exceeded() {
309 let mut session = test_session();
310 session.call_budget = 0;
311 session.calls_made = 0;
312 assert!(
313 session.is_budget_exceeded(),
314 "0 >= 0 means budget is exceeded"
315 );
316 assert!(
317 !session.is_active(),
318 "zero-budget session should not be active"
319 );
320 }
321
322 #[test]
324 fn check_rate_limit_at_exact_window_boundary() {
325 let mut session = test_session();
326 session.rate_limit_per_minute = Some(5);
327 session.rate_window_start =
329 Utc::now() - chrono::Duration::seconds(session.rate_limit_window_secs as i64);
330 session.rate_window_calls = 5; let denied = session.check_rate_limit();
333 assert!(!denied, "exact window boundary should reset and allow");
334 assert_eq!(
335 session.rate_window_calls, 1,
336 "window must reset to 1 after boundary reset"
337 );
338 }
339}