Skip to main content

vellaveto_http_proxy/
session.rs

1// Copyright 2026 Paolo Vella
2// SPDX-License-Identifier: BUSL-1.1
3//
4// Use of this software is governed by the Business Source License
5// included in the LICENSE-BSL-1.1 file at the root of this repository.
6//
7// Change Date: Three years from the date of publication of this version.
8// Change License: MPL-2.0
9
10//! Session management for MCP Streamable HTTP transport.
11//!
12//! Each MCP session is identified by a `Mcp-Session-Id` header. The proxy
13//! tracks per-session state including known tool annotations, protocol
14//! version, and request counts.
15//!
16//! **Status:** Production — fully wired into the HTTP proxy.
17
18use dashmap::DashMap;
19use std::collections::{HashMap, HashSet, VecDeque};
20use std::sync::Arc;
21use std::time::{Duration, Instant};
22use vellaveto_config::ToolManifest;
23use vellaveto_mcp::memory_tracking::MemoryTracker;
24use vellaveto_mcp::rug_pull::ToolAnnotations;
25use vellaveto_types::{AgentIdentity, ReplayStatus};
26
27/// Type alias for backward compatibility with existing code.
28pub type ToolAnnotationsCompact = ToolAnnotations;
29
30/// Per-session state tracked by the HTTP proxy.
31#[derive(Debug)]
32pub struct SessionState {
33    pub session_id: String,
34    /// Opaque approval/audit scope binding for this session.
35    ///
36    /// This is generated by the proxy and intentionally independent from the
37    /// transport-facing `session_id`.
38    pub session_scope_binding: String,
39    pub created_at: Instant,
40    pub last_activity: Instant,
41    pub protocol_version: Option<String>,
42    /// SECURITY (FIND-R52-SESSION-001): Use `pub(crate)` to force callers through
43    /// bounded `insert_known_tool` method. Direct read access within the crate is allowed.
44    pub(crate) known_tools: HashMap<String, ToolAnnotations>,
45    pub request_count: u64,
46    /// Whether the initial tools/list response has been seen for this session.
47    /// Used for rug-pull detection: tool additions after the first list are suspicious.
48    pub tools_list_seen: bool,
49    /// OAuth subject identifier from the authenticated token (if OAuth is enabled).
50    /// Stored for inclusion in audit trail entries.
51    pub oauth_subject: Option<String>,
52    /// Tools flagged by rug-pull detection. Tool calls to these tools are
53    /// blocked until the session is cleared or a clean tools/list is received.
54    /// SECURITY (FIND-R52-SESSION-001): Use `pub(crate)` to force callers through
55    /// bounded `insert_flagged_tool` method. Direct read access within the crate is allowed.
56    pub(crate) flagged_tools: HashSet<String>,
57    /// Pinned tool manifest for this session. Built from the first tools/list
58    /// response, used to verify subsequent tools/list responses.
59    pub pinned_manifest: Option<ToolManifest>,
60    /// Per-tool call counts for context-aware policy evaluation.
61    /// Maps tool name → number of times called in this session.
62    /// SECURITY (FIND-R122-005): `pub(crate)` prevents bypassing bounded
63    /// insertion enforced by MAX_CALL_COUNT_TOOLS.
64    pub(crate) call_counts: HashMap<String, u64>,
65    /// History of tool names called in this session (most recent last).
66    /// Capped at 100 entries to bound memory usage. Uses VecDeque for O(1)
67    /// pop_front instead of O(n) Vec::remove(0) (FIND-046).
68    /// SECURITY (FIND-R222-005): `pub(crate)` prevents bypassing bounded
69    /// insertion enforced by MAX_ACTION_HISTORY.
70    pub(crate) action_history: VecDeque<String>,
71    /// OWASP ASI06: Per-session memory poisoning tracker.
72    /// Records fingerprints of notable strings from tool responses and flags
73    /// when those strings appear verbatim in subsequent tool call parameters.
74    pub memory_tracker: MemoryTracker,
75    /// Number of elicitation requests processed in this session.
76    /// Used for per-session rate limiting of `elicitation/create` requests.
77    pub elicitation_count: u32,
78    /// Number of sampling requests processed in this session.
79    /// Used for per-session rate limiting of `sampling/createMessage` requests.
80    /// SECURITY (FIND-R125-001): Parity with elicitation rate limiting.
81    pub sampling_count: u32,
82    /// Verified detached request-signature nonces observed in this session.
83    /// Used for session-local replay detection when detached signatures are
84    /// promoted to verified provenance.
85    pub(crate) verified_request_nonces: HashSet<String>,
86    /// FIFO order for verified detached request-signature nonces so the session
87    /// replay cache remains bounded.
88    pub(crate) verified_request_nonce_order: VecDeque<String>,
89    /// Pending tool call correlation map: JSON-RPC response id key -> tool name.
90    /// Used to recover tool context for `structuredContent` validation when
91    /// upstream responses omit `result._meta.tool`.
92    /// SECURITY (FIND-R222-004): `pub(crate)` prevents bypassing bounded
93    /// insertion enforced by MAX_PENDING_TOOL_CALLS.
94    pub(crate) pending_tool_calls: HashMap<String, String>,
95    /// SECURITY (R15-OAUTH-4): Token expiry timestamp (Unix seconds).
96    pub token_expires_at: Option<u64>,
97    /// OWASP ASI08: Call chain for multi-agent communication monitoring.
98    /// Tracks upstream agent hops for the latest policy-evaluated request.
99    /// Updated from `X-Upstream-Agents` headers on tool calls, resource reads,
100    /// and task requests.
101    pub current_call_chain: Vec<vellaveto_types::CallChainEntry>,
102    /// OWASP ASI07: Cryptographically attested agent identity from X-Agent-Identity JWT.
103    /// Populated when the header is present and valid, provides stronger identity
104    /// guarantees than the legacy oauth_subject field.
105    pub agent_identity: Option<AgentIdentity>,
106    /// Phase 20: Gateway backend session mapping.
107    /// Maps backend_id → upstream session_id for session affinity.
108    /// SECURITY (FIND-R52-SESSION-001): Use `pub(crate)` to force callers through
109    /// bounded `insert_backend_session` method.
110    pub(crate) backend_sessions: HashMap<String, String>,
111    /// Phase 20: Tools discovered from each gateway backend.
112    /// Maps backend_id → list of tool names for conflict detection.
113    /// SECURITY (FIND-R52-SESSION-001): Use `pub(crate)` to force callers through
114    /// bounded `insert_gateway_tools` method.
115    pub(crate) gateway_tools: HashMap<String, Vec<String>>,
116    /// Phase 21: Per-session risk score for continuous authorization.
117    pub risk_score: Option<vellaveto_types::RiskScore>,
118    /// Phase 21: Granted ABAC policy IDs for least-agency tracking.
119    /// SECURITY (FIND-R52-SESSION-001): Use `pub(crate)` to force callers through
120    /// bounded `insert_granted_policy` method.
121    pub(crate) abac_granted_policies: Vec<String>,
122    /// Phase 34: Tools discovered via `vv_discover` with TTL tracking.
123    /// Maps tool_id → session entry with discovery timestamp and TTL.
124    pub discovered_tools: HashMap<String, DiscoveredToolSession>,
125}
126
127/// Maximum number of discovered tools tracked per session.
128/// Prevents unbounded memory growth from excessive discovery requests.
129const MAX_DISCOVERED_TOOLS_PER_SESSION: usize = 10_000;
130
131/// SECURITY (FIND-R51-001): Maximum backend sessions per client session.
132const MAX_BACKEND_SESSIONS: usize = 128;
133/// SECURITY (FIND-R51-001): Maximum gateway tool entries per client session.
134const MAX_GATEWAY_TOOLS: usize = 128;
135/// SECURITY (FIND-R51-001): Maximum tools per backend in gateway_tools.
136const MAX_TOOLS_PER_BACKEND: usize = 1000;
137
138/// SECURITY (FIND-R51-002): Maximum ABAC granted policies per session.
139const MAX_GRANTED_POLICIES: usize = 1024;
140
141/// SECURITY (FIND-R51-012): Maximum known tools per session.
142const MAX_KNOWN_TOOLS: usize = 2048;
143
144/// SECURITY (FIND-R51-014): Maximum flagged tools per session.
145const MAX_FLAGGED_TOOLS: usize = 2048;
146
147/// SECURITY (R240-PROXY-1): Maximum entries in the global flagged-tools registry.
148/// Prevents unbounded growth when many distinct tools are flagged across sessions.
149const MAX_GLOBAL_FLAGGED_TOOLS: usize = 10_000;
150
151/// SECURITY (R240-PROXY-1): Default TTL for global flagged-tool entries (24 hours).
152/// After this period, a flagged tool is no longer blocked globally.
153/// Operators who want permanent blocking should use policy rules instead.
154const GLOBAL_FLAGGED_TOOL_TTL: Duration = Duration::from_secs(24 * 60 * 60);
155/// SECURITY: Maximum number of verified detached request-signature nonces kept
156/// per session for replay detection.
157const MAX_VERIFIED_REQUEST_NONCES: usize = 1024;
158
159/// Entry in the global flagged-tools registry.
160/// Records when a tool was flagged so it can be expired after TTL.
161#[derive(Debug, Clone)]
162pub struct GlobalFlaggedToolEntry {
163    /// When the tool was first flagged.
164    pub flagged_at: Instant,
165    /// TTL after which this entry expires.
166    pub ttl: Duration,
167}
168
169impl GlobalFlaggedToolEntry {
170    fn is_expired(&self) -> bool {
171        self.flagged_at.elapsed() > self.ttl
172    }
173}
174
175/// Per-session tracking of a discovered tool (Phase 34.3).
176#[derive(Debug, Clone)]
177pub struct DiscoveredToolSession {
178    /// The tool's unique identifier (server_id:tool_name).
179    pub tool_id: String,
180    /// When this tool was discovered.
181    pub discovered_at: Instant,
182    /// How long until this discovery expires.
183    pub ttl: Duration,
184    /// Whether the agent has actually called this tool.
185    pub used: bool,
186}
187
188impl DiscoveredToolSession {
189    /// Check whether this discovery has expired.
190    pub fn is_expired(&self) -> bool {
191        self.discovered_at.elapsed() > self.ttl
192    }
193}
194
195impl SessionState {
196    pub fn new(session_id: String) -> Self {
197        let now = Instant::now();
198        Self {
199            session_id,
200            session_scope_binding: generate_session_scope_binding(),
201            created_at: now,
202            last_activity: now,
203            protocol_version: None,
204            known_tools: HashMap::new(),
205            request_count: 0,
206            tools_list_seen: false,
207            oauth_subject: None,
208            flagged_tools: HashSet::new(),
209            pinned_manifest: None,
210            call_counts: HashMap::new(),
211            action_history: VecDeque::new(),
212            memory_tracker: MemoryTracker::new(),
213            elicitation_count: 0,
214            sampling_count: 0,
215            verified_request_nonces: HashSet::new(),
216            verified_request_nonce_order: VecDeque::new(),
217            pending_tool_calls: HashMap::new(),
218            token_expires_at: None,
219            current_call_chain: Vec::new(),
220            agent_identity: None,
221            backend_sessions: HashMap::new(),
222            gateway_tools: HashMap::new(),
223            risk_score: None,
224            abac_granted_policies: Vec::new(),
225            discovered_tools: HashMap::new(),
226        }
227    }
228
229    // ═══════════════════════════════════════════════════════════════════
230    // SECURITY (FIND-R52-SESSION-001): Read-only accessors for bounded fields.
231    // These allow integration tests and external consumers to inspect state
232    // without bypassing the bounded insertion methods.
233    // ═══════════════════════════════════════════════════════════════════
234
235    /// Read-only access to known tools.
236    pub fn known_tools(&self) -> &HashMap<String, ToolAnnotations> {
237        &self.known_tools
238    }
239
240    /// Read-only access to flagged tools.
241    pub fn flagged_tools(&self) -> &HashSet<String> {
242        &self.flagged_tools
243    }
244
245    /// Read-only access to backend sessions.
246    pub fn backend_sessions(&self) -> &HashMap<String, String> {
247        &self.backend_sessions
248    }
249
250    /// Read-only access to gateway tools.
251    pub fn gateway_tools(&self) -> &HashMap<String, Vec<String>> {
252        &self.gateway_tools
253    }
254
255    /// Read-only access to ABAC granted policies.
256    pub fn abac_granted_policies(&self) -> &[String] {
257        &self.abac_granted_policies
258    }
259
260    /// SECURITY (FIND-R51-001): Insert a backend session with capacity bound.
261    /// Returns `true` if the entry was inserted or already existed, `false` if at capacity.
262    #[allow(clippy::map_entry)] // Capacity check requires len() which conflicts with entry() borrow
263    pub fn insert_backend_session(
264        &mut self,
265        backend_id: String,
266        upstream_session_id: String,
267    ) -> bool {
268        if self.backend_sessions.contains_key(&backend_id) {
269            self.backend_sessions
270                .insert(backend_id, upstream_session_id);
271            return true;
272        }
273        if self.backend_sessions.len() >= MAX_BACKEND_SESSIONS {
274            tracing::warn!(
275                session_id = %self.session_id,
276                capacity = MAX_BACKEND_SESSIONS,
277                "Backend sessions capacity reached; dropping new entry"
278            );
279            return false;
280        }
281        self.backend_sessions
282            .insert(backend_id, upstream_session_id);
283        true
284    }
285
286    /// SECURITY (FIND-R51-001): Insert gateway tools for a backend with capacity bounds.
287    /// Returns `true` if inserted, `false` if at capacity.
288    pub fn insert_gateway_tools(&mut self, backend_id: String, tools: Vec<String>) -> bool {
289        if !self.gateway_tools.contains_key(&backend_id)
290            && self.gateway_tools.len() >= MAX_GATEWAY_TOOLS
291        {
292            tracing::warn!(
293                session_id = %self.session_id,
294                capacity = MAX_GATEWAY_TOOLS,
295                "Gateway tools capacity reached; dropping new backend entry"
296            );
297            return false;
298        }
299        // Truncate the tool list per backend to MAX_TOOLS_PER_BACKEND
300        let bounded_tools: Vec<String> = tools.into_iter().take(MAX_TOOLS_PER_BACKEND).collect();
301        self.gateway_tools.insert(backend_id, bounded_tools);
302        true
303    }
304
305    /// SECURITY (FIND-R51-002): Insert an ABAC granted policy with capacity bound and dedup.
306    pub fn insert_granted_policy(&mut self, policy_id: String) {
307        if !self.abac_granted_policies.contains(&policy_id)
308            && self.abac_granted_policies.len() < MAX_GRANTED_POLICIES
309        {
310            self.abac_granted_policies.push(policy_id);
311        }
312    }
313
314    /// SECURITY (FIND-R51-012): Insert a known tool with capacity bound.
315    /// Returns `true` if inserted or updated, `false` if at capacity.
316    #[allow(clippy::map_entry)] // Capacity check requires len() which conflicts with entry() borrow
317    pub fn insert_known_tool(&mut self, name: String, annotations: ToolAnnotationsCompact) -> bool {
318        if self.known_tools.contains_key(&name) {
319            self.known_tools.insert(name, annotations);
320            return true;
321        }
322        if self.known_tools.len() >= MAX_KNOWN_TOOLS {
323            tracing::warn!(
324                session_id = %self.session_id,
325                capacity = MAX_KNOWN_TOOLS,
326                "Known tools capacity reached; dropping new tool"
327            );
328            return false;
329        }
330        self.known_tools.insert(name, annotations);
331        true
332    }
333
334    /// SECURITY (FIND-R51-014): Insert a flagged tool with capacity bound.
335    pub fn insert_flagged_tool(&mut self, name: String) {
336        if self.flagged_tools.len() < MAX_FLAGGED_TOOLS {
337            self.flagged_tools.insert(name);
338        }
339    }
340
341    /// Record a set of discovered tools with the given TTL.
342    ///
343    /// Overwrites any existing entry for the same tool_id (re-discovery resets the TTL).
344    /// If the session is at capacity (`MAX_DISCOVERED_TOOLS_PER_SESSION`), expired
345    /// entries are evicted first. If still at capacity, new tools are silently dropped.
346    pub fn record_discovered_tools(&mut self, tool_ids: &[String], ttl: Duration) {
347        let now = Instant::now();
348        for tool_id in tool_ids {
349            // Allow overwrites of existing entries without capacity check
350            if !self.discovered_tools.contains_key(tool_id) {
351                if self.discovered_tools.len() >= MAX_DISCOVERED_TOOLS_PER_SESSION {
352                    // Evict expired entries to make room
353                    self.evict_expired_discoveries();
354                }
355                if self.discovered_tools.len() >= MAX_DISCOVERED_TOOLS_PER_SESSION {
356                    tracing::warn!(
357                        session_id = %self.session_id,
358                        capacity = MAX_DISCOVERED_TOOLS_PER_SESSION,
359                        "Discovered tools capacity reached; dropping new tool"
360                    );
361                    continue;
362                }
363            }
364            self.discovered_tools.insert(
365                tool_id.clone(),
366                DiscoveredToolSession {
367                    tool_id: tool_id.clone(),
368                    discovered_at: now,
369                    ttl,
370                    used: false,
371                },
372            );
373        }
374    }
375
376    /// Check whether a discovered tool has expired.
377    ///
378    /// Returns `None` if the tool was never discovered (not an error — the tool
379    /// may be a statically-known tool that doesn't require discovery).
380    /// Returns `Some(true)` if discovered but expired, `Some(false)` if still valid.
381    pub fn is_tool_discovery_expired(&self, tool_id: &str) -> Option<bool> {
382        self.discovered_tools.get(tool_id).map(|d| d.is_expired())
383    }
384
385    /// Mark a discovered tool as "used" (the agent actually called it).
386    ///
387    /// Returns `true` if the tool was found and marked, `false` if not found.
388    pub fn mark_tool_used(&mut self, tool_id: &str) -> bool {
389        if let Some(entry) = self.discovered_tools.get_mut(tool_id) {
390            entry.used = true;
391            true
392        } else {
393            false
394        }
395    }
396
397    /// Remove expired discovered tools from the session.
398    ///
399    /// Returns the number of entries evicted.
400    pub fn evict_expired_discoveries(&mut self) -> usize {
401        let before = self.discovered_tools.len();
402        self.discovered_tools.retain(|_, d| !d.is_expired());
403        before - self.discovered_tools.len()
404    }
405
406    /// Touch the session to update last activity time.
407    pub fn touch(&mut self) {
408        self.last_activity = Instant::now();
409        // SECURITY (FIND-R51-007): Use saturating_add for debug-build safety.
410        self.request_count = self.request_count.saturating_add(1);
411    }
412
413    /// Record a verified detached request-signature nonce for replay detection.
414    pub fn record_verified_request_nonce(&mut self, nonce: &str) -> ReplayStatus {
415        if nonce.is_empty() || vellaveto_types::has_dangerous_chars(nonce) {
416            return ReplayStatus::NotChecked;
417        }
418        if self.verified_request_nonces.contains(nonce) {
419            return ReplayStatus::ReplayDetected;
420        }
421        if self.verified_request_nonce_order.len() >= MAX_VERIFIED_REQUEST_NONCES {
422            if let Some(evicted) = self.verified_request_nonce_order.pop_front() {
423                self.verified_request_nonces.remove(&evicted);
424            }
425        }
426        let bounded_nonce = nonce.to_string();
427        self.verified_request_nonce_order
428            .push_back(bounded_nonce.clone());
429        self.verified_request_nonces.insert(bounded_nonce);
430        ReplayStatus::Fresh
431    }
432
433    /// Check if this session has expired.
434    ///
435    /// A session is expired if either:
436    /// - Inactivity timeout: no activity for longer than `timeout`
437    /// - Absolute lifetime: the session has existed longer than `max_lifetime` (if set)
438    pub fn is_expired(&self, timeout: Duration, max_lifetime: Option<Duration>) -> bool {
439        if self.last_activity.elapsed() > timeout {
440            return true;
441        }
442        if let Some(max) = max_lifetime {
443            if self.created_at.elapsed() > max {
444                return true;
445            }
446        }
447        if let Some(exp) = self.token_expires_at {
448            let now = std::time::SystemTime::now()
449                .duration_since(std::time::UNIX_EPOCH)
450                .unwrap_or_default()
451                .as_secs();
452            if now >= exp {
453                return true;
454            }
455        }
456        false
457    }
458}
459
460fn generate_session_scope_binding() -> String {
461    format!("sidbind:v1:{}", uuid::Uuid::new_v4().simple())
462}
463
464// ═══════════════════════════════════════════════════════════════════
465// Phase 25.6: StatefulContext — RequestContext impl for SessionState
466// ═══════════════════════════════════════════════════════════════════
467
468use vellaveto_types::identity::RequestContext;
469
470/// Adapter that implements [`RequestContext`] for [`SessionState`].
471///
472/// This is the stateful-mode implementation: all context is read from the
473/// in-memory session store. Wrapping `SessionState` in this adapter allows
474/// security-critical code to accept `&dyn RequestContext` and work identically
475/// in both stateful and (future) stateless modes.
476///
477/// # Usage
478///
479/// ```ignore
480/// let ctx = StatefulContext::new(&session);
481/// let eval = ctx.to_evaluation_context();
482/// engine.evaluate(&action, &eval)?;
483/// ```
484pub struct StatefulContext<'a> {
485    session: &'a SessionState,
486    /// Cached Vec of previous actions (converted from VecDeque).
487    /// Lazily populated on first access. Uses OnceLock for Sync.
488    previous_actions_cache: std::sync::OnceLock<Vec<String>>,
489}
490
491impl<'a> StatefulContext<'a> {
492    /// Create a new stateful context wrapping a session reference.
493    pub fn new(session: &'a SessionState) -> Self {
494        Self {
495            session,
496            previous_actions_cache: std::sync::OnceLock::new(),
497        }
498    }
499}
500
501impl RequestContext for StatefulContext<'_> {
502    fn call_counts(&self) -> &HashMap<String, u64> {
503        &self.session.call_counts
504    }
505
506    fn previous_actions(&self) -> &[String] {
507        self.previous_actions_cache
508            .get_or_init(|| self.session.action_history.iter().cloned().collect())
509    }
510
511    fn call_chain(&self) -> &[vellaveto_types::CallChainEntry] {
512        &self.session.current_call_chain
513    }
514
515    fn agent_identity(&self) -> Option<&AgentIdentity> {
516        self.session.agent_identity.as_ref()
517    }
518
519    fn session_guard_state(&self) -> Option<&str> {
520        None // SessionGuard state is tracked separately, not in SessionState fields
521    }
522
523    fn risk_score(&self) -> Option<&vellaveto_types::RiskScore> {
524        self.session.risk_score.as_ref()
525    }
526
527    fn to_evaluation_context(&self) -> vellaveto_types::EvaluationContext {
528        vellaveto_types::EvaluationContext {
529            agent_id: self.session.oauth_subject.clone(),
530            agent_identity: self.session.agent_identity.clone(),
531            call_counts: self.session.call_counts.clone(),
532            previous_actions: self.session.action_history.iter().cloned().collect(),
533            call_chain: self.session.current_call_chain.clone(),
534            session_state: None,
535            ..Default::default()
536        }
537    }
538}
539
540/// SECURITY (R39-PROXY-7): Maximum length for client-provided session IDs.
541/// Server-generated IDs are UUIDs (36 chars). Reject anything longer than
542/// this to prevent memory abuse via arbitrarily long session ID strings.
543const MAX_SESSION_ID_LEN: usize = 128;
544
545/// Thread-safe session store with automatic expiry cleanup.
546pub struct SessionStore {
547    sessions: Arc<DashMap<String, SessionState>>,
548    session_timeout: Duration,
549    max_sessions: usize,
550    /// Optional absolute session lifetime. When set, sessions are expired
551    /// after this duration regardless of activity. Prevents indefinite
552    /// session reuse (e.g., stolen session IDs).
553    max_lifetime: Option<Duration>,
554    /// SECURITY (R240-PROXY-1): Global flagged-tools registry that persists
555    /// rug-pull detections beyond session lifetime. Prevents TOCTOU bypass
556    /// where session eviction (timeout or capacity) drops flagged_tools,
557    /// allowing an attacker to call a rug-pulled tool in a new session.
558    global_flagged_tools: Arc<DashMap<String, GlobalFlaggedToolEntry>>,
559}
560
561impl SessionStore {
562    pub fn new(session_timeout: Duration, max_sessions: usize) -> Self {
563        Self {
564            sessions: Arc::new(DashMap::new()),
565            session_timeout,
566            max_sessions,
567            max_lifetime: None,
568            global_flagged_tools: Arc::new(DashMap::new()),
569        }
570    }
571
572    /// Set an absolute session lifetime. Sessions older than this duration
573    /// are expired regardless of activity. Returns `self` for chaining.
574    pub fn with_max_lifetime(mut self, lifetime: Duration) -> Self {
575        self.max_lifetime = Some(lifetime);
576        self
577    }
578
579    /// Get or create a session. Returns the session ID.
580    ///
581    /// If `client_session_id` is provided and the session exists, it's reused.
582    /// Otherwise a new session is created. Session IDs are always server-generated
583    /// to prevent session fixation attacks.
584    pub fn get_or_create(&self, client_session_id: Option<&str>) -> String {
585        // SECURITY (R39-PROXY-7): Reject oversized session IDs — treat as invalid
586        // to prevent memory abuse. Server-generated IDs are UUIDs (36 chars).
587        // SECURITY (R253-SESS-1): Also reject control/Unicode format characters,
588        // matching parity with HTTP proxy handler validation.
589        let client_session_id = client_session_id.filter(|id| {
590            id.len() <= MAX_SESSION_ID_LEN
591                && !id
592                    .chars()
593                    .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
594        });
595
596        // Try to reuse existing session if client provided an ID
597        if let Some(id) = client_session_id {
598            if let Some(mut session) = self.sessions.get_mut(id) {
599                if !session.is_expired(self.session_timeout, self.max_lifetime) {
600                    session.touch();
601                    return id.to_string();
602                }
603                // Expired — drop and create new
604                drop(session);
605                self.sessions.remove(id);
606            }
607        }
608
609        // Enforce max sessions.
610        // Note: under high concurrency, session count may temporarily exceed
611        // max_sessions by up to the number of concurrent requests. This is a
612        // TOCTOU race inherent to DashMap's non-atomic len()+insert() sequence.
613        // The background cleanup task and per-request eviction correct this
614        // within seconds, so the overshoot is transient and self-correcting.
615        if self.sessions.len() >= self.max_sessions {
616            self.evict_expired();
617            // If still at capacity after cleanup, evict oldest
618            if self.sessions.len() >= self.max_sessions {
619                self.evict_oldest();
620            }
621        }
622
623        // Create new session with server-generated ID
624        let session_id = uuid::Uuid::new_v4().to_string();
625        self.sessions
626            .insert(session_id.clone(), SessionState::new(session_id.clone()));
627        session_id
628    }
629
630    /// Get an immutable reference to a session.
631    pub fn get(
632        &self,
633        session_id: &str,
634    ) -> Option<dashmap::mapref::one::Ref<'_, String, SessionState>> {
635        self.sessions.get(session_id)
636    }
637
638    /// Get a mutable reference to a session.
639    pub fn get_mut(
640        &self,
641        session_id: &str,
642    ) -> Option<dashmap::mapref::one::RefMut<'_, String, SessionState>> {
643        self.sessions.get_mut(session_id)
644    }
645
646    /// Non-blocking read access. Returns `None` if the shard is already locked
647    /// or the session doesn't exist. Use this when the caller may already hold
648    /// a `get_mut()` lock on the same shard to avoid deadlock.
649    pub fn try_get(
650        &self,
651        session_id: &str,
652    ) -> dashmap::try_result::TryResult<dashmap::mapref::one::Ref<'_, String, SessionState>> {
653        self.sessions.try_get(session_id)
654    }
655
656    /// Non-blocking mutable access. Returns `Locked` if the shard is already
657    /// locked. Use this when the caller may already hold a `get_mut()` lock on
658    /// the same shard to avoid deadlock.
659    pub fn try_get_mut(
660        &self,
661        session_id: &str,
662    ) -> dashmap::try_result::TryResult<dashmap::mapref::one::RefMut<'_, String, SessionState>>
663    {
664        self.sessions.try_get_mut(session_id)
665    }
666
667    /// Remove expired sessions.
668    pub fn evict_expired(&self) {
669        self.sessions
670            .retain(|_, session| !session.is_expired(self.session_timeout, self.max_lifetime));
671    }
672
673    /// Remove the oldest session (by last activity).
674    fn evict_oldest(&self) {
675        let oldest = self
676            .sessions
677            .iter()
678            .min_by_key(|entry| entry.value().last_activity)
679            .map(|entry| entry.key().clone());
680
681        if let Some(id) = oldest {
682            self.sessions.remove(&id);
683        }
684    }
685
686    /// Current number of active sessions.
687    pub fn len(&self) -> usize {
688        self.sessions.len()
689    }
690
691    /// Whether there are any active sessions.
692    pub fn is_empty(&self) -> bool {
693        self.sessions.is_empty()
694    }
695
696    /// Delete a specific session (e.g., on client disconnect via DELETE).
697    pub fn remove(&self, session_id: &str) -> bool {
698        self.sessions.remove(session_id).is_some()
699    }
700
701    // =========================================================================
702    // Global Flagged-Tools Registry (R240-PROXY-1)
703    // =========================================================================
704
705    /// Record a tool name in the global flagged-tools registry.
706    ///
707    /// SECURITY (R240-PROXY-1): This ensures rug-pull detections survive session
708    /// eviction. Even if the session that detected the rug-pull is expired or
709    /// evicted under capacity pressure, the tool remains blocked globally.
710    pub fn flag_tool_globally(&self, tool_name: String) {
711        if self.global_flagged_tools.len() >= MAX_GLOBAL_FLAGGED_TOOLS {
712            // Evict expired entries to make room
713            self.evict_expired_global_flags();
714            if self.global_flagged_tools.len() >= MAX_GLOBAL_FLAGGED_TOOLS {
715                tracing::warn!(
716                    tool = %tool_name,
717                    capacity = MAX_GLOBAL_FLAGGED_TOOLS,
718                    "Global flagged-tools registry at capacity; dropping new entry"
719                );
720                return;
721            }
722        }
723        // Only insert if not already present (don't reset TTL on re-flag)
724        self.global_flagged_tools
725            .entry(tool_name)
726            .or_insert_with(|| GlobalFlaggedToolEntry {
727                flagged_at: Instant::now(),
728                ttl: GLOBAL_FLAGGED_TOOL_TTL,
729            });
730    }
731
732    /// Check whether a tool is flagged in the global registry.
733    ///
734    /// SECURITY (R240-PROXY-1): Returns true if the tool was flagged by any
735    /// session and the flag has not yet expired. This is the fallback check
736    /// when a session lookup returns None (session evicted).
737    pub fn is_tool_globally_flagged(&self, tool_name: &str) -> bool {
738        self.global_flagged_tools
739            .get(tool_name)
740            .map(|entry| !entry.is_expired())
741            .unwrap_or(false)
742    }
743
744    /// Remove expired entries from the global flagged-tools registry.
745    pub fn evict_expired_global_flags(&self) -> usize {
746        let before = self.global_flagged_tools.len();
747        self.global_flagged_tools
748            .retain(|_, entry| !entry.is_expired());
749        before.saturating_sub(self.global_flagged_tools.len())
750    }
751
752    /// Number of entries in the global flagged-tools registry.
753    pub fn global_flagged_tools_len(&self) -> usize {
754        self.global_flagged_tools.len()
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761
762    #[test]
763    fn test_session_creation() {
764        let store = SessionStore::new(Duration::from_secs(300), 100);
765        let id = store.get_or_create(None);
766        assert_eq!(id.len(), 36); // UUID format
767        assert_eq!(store.len(), 1);
768    }
769
770    #[test]
771    fn test_session_reuse() {
772        let store = SessionStore::new(Duration::from_secs(300), 100);
773        let id1 = store.get_or_create(None);
774        let id2 = store.get_or_create(Some(&id1));
775        assert_eq!(id1, id2);
776        assert_eq!(store.len(), 1);
777    }
778
779    #[test]
780    fn test_session_unknown_id_creates_new() {
781        let store = SessionStore::new(Duration::from_secs(300), 100);
782        let id = store.get_or_create(Some("nonexistent-id"));
783        assert_ne!(id, "nonexistent-id");
784        assert_eq!(store.len(), 1);
785    }
786
787    #[test]
788    fn test_max_sessions_enforced() {
789        let store = SessionStore::new(Duration::from_secs(300), 3);
790        store.get_or_create(None);
791        store.get_or_create(None);
792        store.get_or_create(None);
793        assert_eq!(store.len(), 3);
794        // 4th session should evict the oldest
795        store.get_or_create(None);
796        assert_eq!(store.len(), 3);
797    }
798
799    #[test]
800    fn test_session_remove() {
801        let store = SessionStore::new(Duration::from_secs(300), 100);
802        let id = store.get_or_create(None);
803        assert!(store.remove(&id));
804        assert_eq!(store.len(), 0);
805        assert!(!store.remove(&id));
806    }
807
808    #[test]
809    fn test_session_touch_increments_count() {
810        let store = SessionStore::new(Duration::from_secs(300), 100);
811        let id = store.get_or_create(None);
812        // First get_or_create doesn't touch (just created)
813        // Second reuse does touch
814        store.get_or_create(Some(&id));
815        let session = store.get_mut(&id).unwrap();
816        assert_eq!(session.request_count, 1);
817    }
818
819    #[test]
820    fn test_flagged_tools_insert_and_contains() {
821        let store = SessionStore::new(Duration::from_secs(300), 100);
822        let id = store.get_or_create(None);
823
824        // Insert flagged tools
825        {
826            let mut session = store.get_mut(&id).unwrap();
827            session.flagged_tools.insert("evil_tool".to_string());
828            session.flagged_tools.insert("suspicious_tool".to_string());
829        }
830
831        // Verify containment
832        let session = store.get_mut(&id).unwrap();
833        assert!(session.flagged_tools.contains("evil_tool"));
834        assert!(session.flagged_tools.contains("suspicious_tool"));
835        assert!(!session.flagged_tools.contains("safe_tool"));
836        assert_eq!(session.flagged_tools.len(), 2);
837    }
838
839    #[test]
840    fn test_flagged_tools_empty_by_default() {
841        let state = SessionState::new("test-session".to_string());
842        assert!(state.flagged_tools.is_empty());
843        assert!(state.pending_tool_calls.is_empty());
844    }
845
846    #[test]
847    fn test_oauth_subject_storage() {
848        let store = SessionStore::new(Duration::from_secs(300), 100);
849        let id = store.get_or_create(None);
850
851        // Initially None
852        {
853            let session = store.get_mut(&id).unwrap();
854            assert!(session.oauth_subject.is_none());
855        }
856
857        // Set subject
858        {
859            let mut session = store.get_mut(&id).unwrap();
860            session.oauth_subject = Some("user-42".to_string());
861        }
862
863        // Verify persistence
864        let session = store.get_mut(&id).unwrap();
865        assert_eq!(session.oauth_subject.as_deref(), Some("user-42"));
866    }
867
868    #[test]
869    fn test_protocol_version_tracking() {
870        let store = SessionStore::new(Duration::from_secs(300), 100);
871        let id = store.get_or_create(None);
872
873        {
874            let session = store.get_mut(&id).unwrap();
875            assert!(session.protocol_version.is_none());
876        }
877
878        {
879            let mut session = store.get_mut(&id).unwrap();
880            session.protocol_version = Some("2025-11-25".to_string());
881        }
882
883        let session = store.get_mut(&id).unwrap();
884        assert_eq!(session.protocol_version.as_deref(), Some("2025-11-25"));
885    }
886
887    #[test]
888    fn test_known_tools_mutations() {
889        let store = SessionStore::new(Duration::from_secs(300), 100);
890        let id = store.get_or_create(None);
891
892        {
893            let mut session = store.get_mut(&id).unwrap();
894            session.known_tools.insert(
895                "read_file".to_string(),
896                ToolAnnotations {
897                    read_only_hint: true,
898                    destructive_hint: false,
899                    idempotent_hint: true,
900                    open_world_hint: false,
901                    input_schema_hash: None,
902                },
903            );
904        }
905
906        let session = store.get_mut(&id).unwrap();
907        assert_eq!(session.known_tools.len(), 1);
908        let ann = session.known_tools.get("read_file").unwrap();
909        assert!(ann.read_only_hint);
910        assert!(!ann.destructive_hint);
911    }
912
913    #[test]
914    fn test_tool_annotations_default() {
915        let ann = ToolAnnotations::default();
916        assert!(!ann.read_only_hint);
917        assert!(ann.destructive_hint);
918        assert!(!ann.idempotent_hint);
919        assert!(ann.open_world_hint);
920    }
921
922    #[test]
923    fn test_tool_annotations_equality() {
924        let a = ToolAnnotations {
925            read_only_hint: true,
926            destructive_hint: false,
927            idempotent_hint: true,
928            open_world_hint: false,
929            input_schema_hash: None,
930        };
931        let b = ToolAnnotations {
932            read_only_hint: true,
933            destructive_hint: false,
934            idempotent_hint: true,
935            open_world_hint: false,
936            input_schema_hash: None,
937        };
938        let c = ToolAnnotations::default();
939        assert_eq!(a, b);
940        assert_ne!(a, c);
941    }
942
943    #[test]
944    fn test_tools_list_seen_flag() {
945        let state = SessionState::new("test".to_string());
946        assert!(!state.tools_list_seen);
947    }
948
949    // --- Phase 5B: Absolute session lifetime tests ---
950
951    #[test]
952    fn test_inactivity_expiry_preserved() {
953        let state = SessionState::new("test-inactivity".to_string());
954        // Not expired with generous timeout, no max_lifetime
955        assert!(!state.is_expired(Duration::from_secs(300), None));
956        // Expired with zero timeout (any elapsed time exceeds 0)
957        assert!(state.is_expired(Duration::from_nanos(0), None));
958    }
959
960    #[test]
961    fn test_absolute_lifetime_enforced() {
962        let state = SessionState::new("test-lifetime".to_string());
963        // With a zero max_lifetime, should be expired immediately (created_at has elapsed > 0)
964        assert!(state.is_expired(Duration::from_secs(300), Some(Duration::from_nanos(0))));
965        // With generous max_lifetime, should not be expired
966        assert!(!state.is_expired(Duration::from_secs(300), Some(Duration::from_secs(86400))));
967    }
968
969    #[test]
970    fn test_none_max_lifetime_no_absolute_limit() {
971        let state = SessionState::new("test-no-limit".to_string());
972        // Without max_lifetime, only inactivity timeout matters
973        assert!(!state.is_expired(Duration::from_secs(300), None));
974    }
975
976    #[test]
977    fn test_eviction_checks_both_timeouts() {
978        // Create a store with a very short max_lifetime
979        let store = SessionStore::new(Duration::from_secs(300), 100)
980            .with_max_lifetime(Duration::from_nanos(0));
981
982        let _id = store.get_or_create(None);
983        assert_eq!(store.len(), 1);
984
985        // Evict expired should remove the session (max_lifetime exceeded)
986        store.evict_expired();
987        assert_eq!(store.len(), 0);
988    }
989
990    #[test]
991    fn test_with_max_lifetime_builder() {
992        let store = SessionStore::new(Duration::from_secs(300), 100)
993            .with_max_lifetime(Duration::from_secs(86400));
994        // Session should be created and accessible
995        let id = store.get_or_create(None);
996        assert_eq!(store.len(), 1);
997        // Can reuse the session (not expired)
998        let id2 = store.get_or_create(Some(&id));
999        assert_eq!(id, id2);
1000    }
1001
1002    // --- R39-PROXY-7: Session ID length validation ---
1003
1004    #[test]
1005    fn test_session_id_at_max_length_accepted() {
1006        let store = SessionStore::new(Duration::from_secs(300), 100);
1007        // Create a session with a 128-char ID first, then try to reuse it
1008        let long_id = "a".repeat(MAX_SESSION_ID_LEN);
1009        // Since the session doesn't exist, a new one is created
1010        let id = store.get_or_create(Some(&long_id));
1011        assert_ne!(id, long_id); // Server-generated, not client ID
1012        assert_eq!(store.len(), 1);
1013
1014        // Now manually insert with the long ID and verify reuse works
1015        store
1016            .sessions
1017            .insert(long_id.clone(), SessionState::new(long_id.clone()));
1018        let reused = store.get_or_create(Some(&long_id));
1019        assert_eq!(reused, long_id);
1020    }
1021
1022    #[test]
1023    fn test_session_id_exceeding_max_length_rejected() {
1024        let store = SessionStore::new(Duration::from_secs(300), 100);
1025        // Insert a session with a 129-char ID manually
1026        let too_long = "b".repeat(MAX_SESSION_ID_LEN + 1);
1027        store
1028            .sessions
1029            .insert(too_long.clone(), SessionState::new(too_long.clone()));
1030
1031        // Even though the session exists, the oversized ID should be rejected
1032        // and a new server-generated session ID returned
1033        let id = store.get_or_create(Some(&too_long));
1034        assert_ne!(id, too_long, "Oversized session ID must not be reused");
1035        assert_eq!(id.len(), 36, "Should return a UUID-format session ID");
1036    }
1037
1038    #[test]
1039    fn test_session_id_empty_string_accepted() {
1040        let store = SessionStore::new(Duration::from_secs(300), 100);
1041        // Empty string is within the length limit but won't match any session
1042        let id = store.get_or_create(Some(""));
1043        assert_eq!(id.len(), 36); // New UUID generated
1044        assert_eq!(store.len(), 1);
1045    }
1046
1047    #[test]
1048    fn test_session_id_exactly_128_chars_boundary() {
1049        let store = SessionStore::new(Duration::from_secs(300), 100);
1050        let exact = "x".repeat(128);
1051        // Should be treated as valid (not rejected)
1052        let id = store.get_or_create(Some(&exact));
1053        // Session doesn't exist, so new one is created, but the ID was accepted
1054        // for lookup (just not found)
1055        assert_eq!(id.len(), 36);
1056
1057        let one_over = "x".repeat(129);
1058        let id2 = store.get_or_create(Some(&one_over));
1059        assert_eq!(id2.len(), 36);
1060        // Both should have created new sessions
1061        assert_eq!(store.len(), 2);
1062    }
1063
1064    // ═══════════════════════════════════════════════════
1065    // Phase 25.6: StatefulContext tests
1066    // ═══════════════════════════════════════════════════
1067
1068    /// Phase 25.6: StatefulContext implements RequestContext trait.
1069    #[test]
1070    fn test_stateful_context_implements_trait() {
1071        let session = SessionState::new("test-ctx".to_string());
1072        let ctx = StatefulContext::new(&session);
1073
1074        // Verify trait methods work
1075        let _: &dyn RequestContext = &ctx;
1076        assert!(ctx.call_counts().is_empty());
1077        assert!(ctx.previous_actions().is_empty());
1078        assert!(ctx.call_chain().is_empty());
1079        assert!(ctx.agent_identity().is_none());
1080        assert!(ctx.session_guard_state().is_none());
1081        assert!(ctx.risk_score().is_none());
1082    }
1083
1084    /// Phase 25.6: call_counts() returns session's call counts.
1085    #[test]
1086    fn test_stateful_context_call_counts() {
1087        let mut session = SessionState::new("test-counts".to_string());
1088        session.call_counts.insert("read_file".to_string(), 5);
1089        session.call_counts.insert("write_file".to_string(), 3);
1090
1091        let ctx = StatefulContext::new(&session);
1092        assert_eq!(ctx.call_counts().len(), 2);
1093        assert_eq!(ctx.call_counts()["read_file"], 5);
1094        assert_eq!(ctx.call_counts()["write_file"], 3);
1095    }
1096
1097    /// Phase 25.6: previous_actions() returns session's action history.
1098    #[test]
1099    fn test_stateful_context_previous_actions() {
1100        let mut session = SessionState::new("test-actions".to_string());
1101        session.action_history.push_back("read_file".to_string());
1102        session.action_history.push_back("write_file".to_string());
1103        session.action_history.push_back("execute".to_string());
1104
1105        let ctx = StatefulContext::new(&session);
1106        let actions = ctx.previous_actions();
1107        assert_eq!(actions.len(), 3);
1108        assert_eq!(actions[0], "read_file");
1109        assert_eq!(actions[1], "write_file");
1110        assert_eq!(actions[2], "execute");
1111    }
1112
1113    // ═══════════════════════════════════════════════════
1114    // Phase 34.3: Discovered tools TTL tests
1115    // ═══════════════════════════════════════════════════
1116
1117    #[test]
1118    fn test_discovered_tools_empty_by_default() {
1119        let state = SessionState::new("test".to_string());
1120        assert!(state.discovered_tools.is_empty());
1121    }
1122
1123    #[test]
1124    fn test_record_discovered_tools() {
1125        let mut state = SessionState::new("test".to_string());
1126        let tools = vec![
1127            "server:read_file".to_string(),
1128            "server:write_file".to_string(),
1129        ];
1130        state.record_discovered_tools(&tools, Duration::from_secs(300));
1131
1132        assert_eq!(state.discovered_tools.len(), 2);
1133        assert!(state.discovered_tools.contains_key("server:read_file"));
1134        assert!(state.discovered_tools.contains_key("server:write_file"));
1135    }
1136
1137    #[test]
1138    fn test_record_discovered_tools_sets_ttl() {
1139        let mut state = SessionState::new("test".to_string());
1140        state.record_discovered_tools(&["server:tool1".to_string()], Duration::from_secs(60));
1141
1142        let entry = state.discovered_tools.get("server:tool1").unwrap();
1143        assert_eq!(entry.ttl, Duration::from_secs(60));
1144        assert!(!entry.used);
1145    }
1146
1147    #[test]
1148    fn test_record_discovered_tools_rediscovery_resets_ttl() {
1149        let mut state = SessionState::new("test".to_string());
1150        state.record_discovered_tools(&["server:tool1".to_string()], Duration::from_secs(60));
1151
1152        // Mark as used
1153        state.mark_tool_used("server:tool1");
1154        assert!(state.discovered_tools.get("server:tool1").unwrap().used);
1155
1156        // Re-discover resets TTL and used flag
1157        state.record_discovered_tools(&["server:tool1".to_string()], Duration::from_secs(120));
1158
1159        let entry = state.discovered_tools.get("server:tool1").unwrap();
1160        assert_eq!(entry.ttl, Duration::from_secs(120));
1161        assert!(!entry.used); // reset on re-discovery
1162    }
1163
1164    #[test]
1165    fn test_is_tool_discovery_expired_unknown_tool() {
1166        let state = SessionState::new("test".to_string());
1167        assert_eq!(state.is_tool_discovery_expired("unknown:tool"), None);
1168    }
1169
1170    #[test]
1171    fn test_is_tool_discovery_expired_fresh_tool() {
1172        let mut state = SessionState::new("test".to_string());
1173        state.record_discovered_tools(&["server:tool1".to_string()], Duration::from_secs(300));
1174        assert_eq!(state.is_tool_discovery_expired("server:tool1"), Some(false));
1175    }
1176
1177    #[test]
1178    fn test_is_tool_discovery_expired_zero_ttl() {
1179        let mut state = SessionState::new("test".to_string());
1180        // Zero TTL means expired immediately
1181        state.discovered_tools.insert(
1182            "server:tool1".to_string(),
1183            DiscoveredToolSession {
1184                tool_id: "server:tool1".to_string(),
1185                discovered_at: Instant::now() - Duration::from_secs(1),
1186                ttl: Duration::from_nanos(0),
1187                used: false,
1188            },
1189        );
1190        assert_eq!(state.is_tool_discovery_expired("server:tool1"), Some(true));
1191    }
1192
1193    #[test]
1194    fn test_mark_tool_used_existing() {
1195        let mut state = SessionState::new("test".to_string());
1196        state.record_discovered_tools(&["server:tool1".to_string()], Duration::from_secs(300));
1197        assert!(!state.discovered_tools.get("server:tool1").unwrap().used);
1198
1199        assert!(state.mark_tool_used("server:tool1"));
1200        assert!(state.discovered_tools.get("server:tool1").unwrap().used);
1201    }
1202
1203    #[test]
1204    fn test_mark_tool_used_nonexistent() {
1205        let mut state = SessionState::new("test".to_string());
1206        assert!(!state.mark_tool_used("unknown:tool"));
1207    }
1208
1209    #[test]
1210    fn test_evict_expired_discoveries_none_expired() {
1211        let mut state = SessionState::new("test".to_string());
1212        state.record_discovered_tools(
1213            &["server:tool1".to_string(), "server:tool2".to_string()],
1214            Duration::from_secs(300),
1215        );
1216        assert_eq!(state.evict_expired_discoveries(), 0);
1217        assert_eq!(state.discovered_tools.len(), 2);
1218    }
1219
1220    #[test]
1221    fn test_evict_expired_discoveries_some_expired() {
1222        let mut state = SessionState::new("test".to_string());
1223
1224        // Fresh tool
1225        state.record_discovered_tools(&["server:fresh".to_string()], Duration::from_secs(300));
1226
1227        // Expired tool (discovered in the past with short TTL)
1228        state.discovered_tools.insert(
1229            "server:stale".to_string(),
1230            DiscoveredToolSession {
1231                tool_id: "server:stale".to_string(),
1232                discovered_at: Instant::now() - Duration::from_secs(10),
1233                ttl: Duration::from_secs(1),
1234                used: true,
1235            },
1236        );
1237
1238        assert_eq!(state.evict_expired_discoveries(), 1);
1239        assert_eq!(state.discovered_tools.len(), 1);
1240        assert!(state.discovered_tools.contains_key("server:fresh"));
1241        assert!(!state.discovered_tools.contains_key("server:stale"));
1242    }
1243
1244    #[test]
1245    fn test_evict_expired_discoveries_all_expired() {
1246        let mut state = SessionState::new("test".to_string());
1247        let past = Instant::now() - Duration::from_secs(10);
1248        for i in 0..5 {
1249            state.discovered_tools.insert(
1250                format!("server:tool{i}"),
1251                DiscoveredToolSession {
1252                    tool_id: format!("server:tool{i}"),
1253                    discovered_at: past,
1254                    ttl: Duration::from_secs(1),
1255                    used: false,
1256                },
1257            );
1258        }
1259
1260        assert_eq!(state.evict_expired_discoveries(), 5);
1261        assert!(state.discovered_tools.is_empty());
1262    }
1263
1264    #[test]
1265    fn test_discovered_tool_session_is_expired() {
1266        let fresh = DiscoveredToolSession {
1267            tool_id: "t".to_string(),
1268            discovered_at: Instant::now(),
1269            ttl: Duration::from_secs(300),
1270            used: false,
1271        };
1272        assert!(!fresh.is_expired());
1273
1274        let stale = DiscoveredToolSession {
1275            tool_id: "t".to_string(),
1276            discovered_at: Instant::now() - Duration::from_secs(10),
1277            ttl: Duration::from_secs(1),
1278            used: false,
1279        };
1280        assert!(stale.is_expired());
1281    }
1282
1283    #[test]
1284    fn test_discovered_tools_survive_session_touch() {
1285        let store = SessionStore::new(Duration::from_secs(300), 100);
1286        let id = store.get_or_create(None);
1287
1288        // Record a discovered tool
1289        {
1290            let mut session = store.get_mut(&id).unwrap();
1291            session
1292                .record_discovered_tools(&["server:tool1".to_string()], Duration::from_secs(300));
1293        }
1294
1295        // Touch via reuse
1296        store.get_or_create(Some(&id));
1297
1298        // Discovered tools should persist
1299        let session = store.get_mut(&id).unwrap();
1300        assert_eq!(session.discovered_tools.len(), 1);
1301        assert!(session.discovered_tools.contains_key("server:tool1"));
1302    }
1303
1304    #[test]
1305    fn test_multiple_tools_independent_ttl() {
1306        let mut state = SessionState::new("test".to_string());
1307
1308        // Tool with short TTL (already expired)
1309        state.discovered_tools.insert(
1310            "server:short".to_string(),
1311            DiscoveredToolSession {
1312                tool_id: "server:short".to_string(),
1313                discovered_at: Instant::now() - Duration::from_secs(5),
1314                ttl: Duration::from_secs(1),
1315                used: false,
1316            },
1317        );
1318
1319        // Tool with long TTL (still valid)
1320        state.record_discovered_tools(&["server:long".to_string()], Duration::from_secs(3600));
1321
1322        assert_eq!(state.is_tool_discovery_expired("server:short"), Some(true));
1323        assert_eq!(state.is_tool_discovery_expired("server:long"), Some(false));
1324    }
1325
1326    /// Phase 25.6: EvaluationContext built from StatefulContext.
1327    #[test]
1328    fn test_evaluation_context_from_stateful() {
1329        let mut session = SessionState::new("test-eval".to_string());
1330        session.oauth_subject = Some("user-42".to_string());
1331        session.call_counts.insert("tool_a".to_string(), 7);
1332        session.action_history.push_back("tool_a".to_string());
1333        session.agent_identity = Some(AgentIdentity {
1334            issuer: Some("test-issuer".to_string()),
1335            subject: Some("agent-sub".to_string()),
1336            ..Default::default()
1337        });
1338
1339        let ctx = StatefulContext::new(&session);
1340        let eval = ctx.to_evaluation_context();
1341
1342        assert_eq!(eval.agent_id.as_deref(), Some("user-42"));
1343        assert_eq!(eval.call_counts["tool_a"], 7);
1344        assert_eq!(eval.previous_actions, vec!["tool_a".to_string()]);
1345        assert_eq!(
1346            eval.agent_identity.as_ref().unwrap().issuer.as_deref(),
1347            Some("test-issuer")
1348        );
1349    }
1350
1351    // =========================================================================
1352    // Global Flagged-Tools Registry Tests (R240-PROXY-1)
1353    // =========================================================================
1354
1355    #[test]
1356    fn test_global_flagged_tool_basic() {
1357        let store = SessionStore::new(Duration::from_secs(300), 100);
1358        assert!(!store.is_tool_globally_flagged("evil_tool"));
1359        assert_eq!(store.global_flagged_tools_len(), 0);
1360
1361        store.flag_tool_globally("evil_tool".to_string());
1362        assert!(store.is_tool_globally_flagged("evil_tool"));
1363        assert!(!store.is_tool_globally_flagged("safe_tool"));
1364        assert_eq!(store.global_flagged_tools_len(), 1);
1365    }
1366
1367    #[test]
1368    fn test_global_flagged_tool_survives_session_eviction() {
1369        // This is the core TOCTOU fix test: flag a tool in a session,
1370        // evict the session, verify the tool is still globally flagged.
1371        let store = SessionStore::new(Duration::from_secs(300), 2);
1372        let id1 = store.get_or_create(None);
1373
1374        // Flag a tool in session
1375        if let Some(mut s) = store.get_mut(&id1) {
1376            s.insert_flagged_tool("rug_pulled_tool".to_string());
1377        }
1378        // Also record globally (as helpers.rs does)
1379        store.flag_tool_globally("rug_pulled_tool".to_string());
1380
1381        // Verify session-local check works
1382        let is_flagged = store
1383            .get_mut(&id1)
1384            .map(|s| s.flagged_tools.contains("rug_pulled_tool"))
1385            .unwrap_or(false);
1386        assert!(is_flagged);
1387
1388        // Evict by creating enough sessions to exceed capacity
1389        store.get_or_create(None);
1390        store.get_or_create(None); // triggers eviction of oldest (id1)
1391
1392        // Session is gone — old check would return false (TOCTOU!)
1393        let session_gone = store.get_mut(&id1).is_none();
1394        assert!(session_gone, "session should have been evicted");
1395
1396        // Global registry still catches it — TOCTOU fixed
1397        assert!(store.is_tool_globally_flagged("rug_pulled_tool"));
1398    }
1399
1400    #[test]
1401    fn test_global_flagged_tool_expiry() {
1402        let store = SessionStore::new(Duration::from_secs(300), 100);
1403
1404        // Insert with a very short TTL by manipulating the entry directly
1405        store.global_flagged_tools.insert(
1406            "expired_tool".to_string(),
1407            GlobalFlaggedToolEntry {
1408                flagged_at: Instant::now() - Duration::from_secs(25 * 60 * 60), // 25h ago
1409                ttl: GLOBAL_FLAGGED_TOOL_TTL,                                   // 24h TTL
1410            },
1411        );
1412
1413        // Should be expired
1414        assert!(!store.is_tool_globally_flagged("expired_tool"));
1415
1416        // Eviction should remove it
1417        let evicted = store.evict_expired_global_flags();
1418        assert_eq!(evicted, 1);
1419        assert_eq!(store.global_flagged_tools_len(), 0);
1420    }
1421
1422    #[test]
1423    fn test_global_flagged_tool_capacity_bound() {
1424        let store = SessionStore::new(Duration::from_secs(300), 100);
1425
1426        // Fill to capacity
1427        for i in 0..MAX_GLOBAL_FLAGGED_TOOLS {
1428            store.flag_tool_globally(format!("tool_{i}"));
1429        }
1430        assert_eq!(store.global_flagged_tools_len(), MAX_GLOBAL_FLAGGED_TOOLS);
1431
1432        // One more should be silently dropped (capacity reached)
1433        store.flag_tool_globally("overflow_tool".to_string());
1434        assert!(!store.is_tool_globally_flagged("overflow_tool"));
1435        assert_eq!(store.global_flagged_tools_len(), MAX_GLOBAL_FLAGGED_TOOLS);
1436    }
1437
1438    #[test]
1439    fn test_global_flagged_tool_capacity_evicts_expired_first() {
1440        let store = SessionStore::new(Duration::from_secs(300), 100);
1441
1442        // Fill to capacity with expired entries
1443        for i in 0..MAX_GLOBAL_FLAGGED_TOOLS {
1444            store.global_flagged_tools.insert(
1445                format!("old_tool_{i}"),
1446                GlobalFlaggedToolEntry {
1447                    flagged_at: Instant::now() - Duration::from_secs(25 * 60 * 60),
1448                    ttl: GLOBAL_FLAGGED_TOOL_TTL,
1449                },
1450            );
1451        }
1452        assert_eq!(store.global_flagged_tools_len(), MAX_GLOBAL_FLAGGED_TOOLS);
1453
1454        // New flag should succeed after evicting expired entries
1455        store.flag_tool_globally("fresh_tool".to_string());
1456        assert!(store.is_tool_globally_flagged("fresh_tool"));
1457    }
1458
1459    #[test]
1460    fn test_global_flagged_tool_no_ttl_reset_on_reflag() {
1461        let store = SessionStore::new(Duration::from_secs(300), 100);
1462
1463        // Insert with a known timestamp
1464        let old_time = Instant::now() - Duration::from_secs(60 * 60); // 1h ago
1465        store.global_flagged_tools.insert(
1466            "tool_a".to_string(),
1467            GlobalFlaggedToolEntry {
1468                flagged_at: old_time,
1469                ttl: GLOBAL_FLAGGED_TOOL_TTL,
1470            },
1471        );
1472
1473        // Re-flag should NOT reset the timestamp (or_insert, not insert)
1474        store.flag_tool_globally("tool_a".to_string());
1475        let entry = store.global_flagged_tools.get("tool_a").unwrap();
1476        assert_eq!(entry.flagged_at, old_time);
1477    }
1478
1479    #[test]
1480    fn test_global_flagged_tool_unwrap_or_else_fallback() {
1481        // Simulate what the handler code does: session lookup fails,
1482        // falls back to global registry.
1483        let store = SessionStore::new(Duration::from_secs(300), 100);
1484        store.flag_tool_globally("globally_flagged".to_string());
1485
1486        // Session doesn't exist — simulates evicted session
1487        let is_flagged = store
1488            .get_mut("nonexistent-session")
1489            .map(|s| s.flagged_tools.contains("globally_flagged"))
1490            .unwrap_or_else(|| store.is_tool_globally_flagged("globally_flagged"));
1491
1492        assert!(is_flagged, "global fallback should catch flagged tool");
1493    }
1494
1495    #[test]
1496    fn test_r253_get_or_create_rejects_control_chars_in_session_id() {
1497        let store = SessionStore::new(Duration::from_secs(300), 100);
1498
1499        // Create a session first
1500        let id = store.get_or_create(None);
1501
1502        // Reuse with valid ID works
1503        let reused = store.get_or_create(Some(&id));
1504        assert_eq!(id, reused);
1505
1506        // Control character in session ID — should be treated as invalid
1507        // and create a new session instead of looking up
1508        let new_id = store.get_or_create(Some("session\x00id"));
1509        assert_ne!(new_id, "session\x00id");
1510
1511        // Zero-width space U+200B — Unicode format character
1512        let new_id2 = store.get_or_create(Some("session\u{200B}id"));
1513        assert_ne!(new_id2, "session\u{200B}id");
1514    }
1515}