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    /// Credential references this session is authorized to resolve.
50    /// Empty means no credentials are allowed (deny-by-default).
51    /// This prevents agents from injecting `${CRED:admin_password}` inside
52    /// an authorized tool call's arguments when they should only access
53    /// their own credentials.
54    #[serde(default)]
55    pub authorized_credentials: Vec<String>,
56
57    /// Maximum duration for this session.
58    pub time_limit: chrono::Duration,
59
60    /// Maximum number of tool calls allowed.
61    pub call_budget: u64,
62
63    /// Number of tool calls made so far.
64    pub calls_made: u64,
65
66    /// Per-minute rate limit. `None` means no rate limit (only lifetime budget applies).
67    #[serde(default)]
68    pub rate_limit_per_minute: Option<u64>,
69
70    /// Start of the current rate-limit window.
71    #[serde(default = "Utc::now")]
72    pub rate_window_start: DateTime<Utc>,
73
74    /// Number of calls within the current rate-limit window.
75    #[serde(default)]
76    pub rate_window_calls: u64,
77
78    /// Duration of the rate-limit window in seconds. Defaults to 60.
79    #[serde(default = "default_rate_limit_window_secs")]
80    pub rate_limit_window_secs: u64,
81
82    /// Maximum data sensitivity this session may access.
83    pub data_sensitivity_ceiling: DataSensitivity,
84
85    /// When this session was created.
86    pub created_at: DateTime<Utc>,
87
88    /// Current session status.
89    pub status: SessionStatus,
90}
91
92impl TaskSession {
93    /// Returns true if the session has exceeded its time limit.
94    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    /// Returns true if the session's call budget is exhausted.
100    pub fn is_budget_exceeded(&self) -> bool {
101        self.calls_made >= self.call_budget
102    }
103
104    /// Returns true if the given tool is authorized in this session.
105    pub fn is_tool_authorized(&self, tool_name: &str) -> bool {
106        // Empty authorized_tools means "all tools allowed" (wide-open session).
107        self.authorized_tools.is_empty() || self.authorized_tools.iter().any(|t| t == tool_name)
108    }
109
110    /// Returns true if the given credential reference is authorized in this session.
111    /// Empty authorized_credentials means all credentials are allowed (wide-open,
112    /// matching the same pattern as `is_tool_authorized` for backward compatibility).
113    /// Non-empty means only the listed credential references are permitted.
114    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    /// Returns true if the session is active and usable.
120    pub fn is_active(&self) -> bool {
121        self.status == SessionStatus::Active && !self.is_expired() && !self.is_budget_exceeded()
122    }
123
124    /// Check and update the rate-limit window. Returns true if the call
125    /// should be rejected due to rate limiting.
126    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            // New window. Reset.
135            self.rate_window_start = now;
136            self.rate_window_calls = 1;
137            false
138        } else if self.rate_window_calls >= limit {
139            true // rate limited
140        } 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        // With no rate limit configured, window calls should stay at zero
215        // because the method returns early before touching the counter.
216        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        // Simulate that the window already has 3 calls recorded.
238        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        // Push the window start 61 seconds into the past so the window is expired.
250        session.rate_window_start = Utc::now() - chrono::Duration::seconds(61);
251        session.rate_window_calls = 5; // was at limit in the old window
252
253        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        // Confirm it is NOT because of expiry or budget.
288        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    /// A session with call_budget=0 must report budget exceeded immediately.
307    #[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    /// When elapsed == window duration exactly, the window should reset.
323    #[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        // Set the window start exactly `rate_limit_window_secs` ago.
328        session.rate_window_start =
329            Utc::now() - chrono::Duration::seconds(session.rate_limit_window_secs as i64);
330        session.rate_window_calls = 5; // was at limit in old window
331
332        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}