1use std::collections::{BTreeMap, HashMap};
32use std::hash::{Hash, Hasher};
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::sync::RwLock;
35use std::time::{Duration, Instant};
36
37use vellaveto_types::{Action, EvaluationContext, Verdict};
38
39pub const MAX_CACHE_ENTRIES: usize = 100_000;
41
42pub const MIN_TTL_SECS: u64 = 1;
44
45pub const MAX_TTL_SECS: u64 = 3600;
47
48#[derive(Hash, Eq, PartialEq, Clone, Debug)]
54struct CacheKey {
55 tool_hash: u64,
56 function_hash: u64,
57 paths_hash: u64,
58 domains_hash: u64,
59 resolved_ips_hash: u64,
63 identity_hash: u64,
64 parameters_hash: u64,
68}
69
70struct CacheEntry {
72 verdict: Verdict,
73 inserted_at: Instant,
74 generation: u64,
75 last_accessed: u64,
77}
78
79#[derive(Debug, Clone, Default)]
81pub struct CacheStats {
82 pub hits: u64,
83 pub misses: u64,
84 pub evictions: u64,
85 pub insertions: u64,
86 pub invalidations: u64,
87}
88
89struct CacheInner {
95 entries: HashMap<CacheKey, CacheEntry>,
96 lru_index: BTreeMap<u64, CacheKey>,
98}
99
100pub struct DecisionCache {
105 inner: RwLock<CacheInner>,
106 max_entries: usize,
107 ttl: Duration,
108 policy_generation: AtomicU64,
109 hits: AtomicU64,
111 misses: AtomicU64,
112 evictions: AtomicU64,
113 insertions: AtomicU64,
114 invalidations: AtomicU64,
115 access_counter: AtomicU64,
117}
118
119impl std::fmt::Debug for DecisionCache {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("DecisionCache")
122 .field("max_entries", &self.max_entries)
123 .field("ttl", &self.ttl)
124 .field(
125 "policy_generation",
126 &self.policy_generation.load(Ordering::SeqCst),
127 )
128 .field(
129 "current_size",
130 &self
131 .inner
132 .read()
133 .map(|c| c.entries.len())
134 .unwrap_or_default(),
135 )
136 .finish()
137 }
138}
139
140impl DecisionCache {
141 pub fn new(max_entries: usize, ttl: Duration) -> Self {
150 let clamped_max = max_entries.clamp(1, MAX_CACHE_ENTRIES);
151 let clamped_ttl_secs = ttl.as_secs().clamp(MIN_TTL_SECS, MAX_TTL_SECS);
152 let clamped_ttl = Duration::from_secs(clamped_ttl_secs);
153
154 Self {
155 inner: RwLock::new(CacheInner {
156 entries: HashMap::with_capacity(clamped_max.min(1024)),
157 lru_index: BTreeMap::new(),
158 }),
159 max_entries: clamped_max,
160 ttl: clamped_ttl,
161 policy_generation: AtomicU64::new(0),
162 hits: AtomicU64::new(0),
163 misses: AtomicU64::new(0),
164 evictions: AtomicU64::new(0),
165 insertions: AtomicU64::new(0),
166 invalidations: AtomicU64::new(0),
167 access_counter: AtomicU64::new(0),
168 }
169 }
170
171 pub fn get_with_risk(
187 &self,
188 action: &Action,
189 context: Option<&EvaluationContext>,
190 has_risk_score: bool,
191 ) -> Option<Verdict> {
192 if !Self::is_cacheable_context(context, has_risk_score) {
193 self.misses.fetch_add(1, Ordering::Relaxed);
194 return None;
195 }
196
197 let key = Self::build_key(action, context);
198 let current_gen = self.policy_generation.load(Ordering::SeqCst);
199
200 let inner = match self.inner.read() {
202 Ok(guard) => guard,
203 Err(_) => {
204 self.misses.fetch_add(1, Ordering::Relaxed);
205 return None;
206 }
207 };
208
209 match inner.entries.get(&key) {
210 Some(entry)
211 if entry.generation == current_gen && entry.inserted_at.elapsed() < self.ttl =>
212 {
213 self.hits.fetch_add(1, Ordering::Relaxed);
214 Some(entry.verdict.clone())
219 }
220 _ => {
221 self.misses.fetch_add(1, Ordering::Relaxed);
222 None
223 }
224 }
225 }
226
227 pub fn get(&self, action: &Action, context: Option<&EvaluationContext>) -> Option<Verdict> {
231 self.get_with_risk(action, context, false)
232 }
233
234 pub fn insert_with_risk(
248 &self,
249 action: &Action,
250 context: Option<&EvaluationContext>,
251 verdict: &Verdict,
252 has_risk_score: bool,
253 ) {
254 if !Self::is_cacheable_context(context, has_risk_score) {
255 return;
256 }
257
258 let key = Self::build_key(action, context);
259 let current_gen = self.policy_generation.load(Ordering::SeqCst);
260 let access_order = self.access_counter.fetch_add(1, Ordering::SeqCst);
263
264 let mut inner = match self.inner.write() {
266 Ok(guard) => guard,
267 Err(_) => return,
268 };
269
270 if let Some(old_access) = inner.entries.get(&key).map(|e| e.last_accessed) {
272 inner.lru_index.remove(&old_access);
273 }
274
275 if inner.entries.len() >= self.max_entries && !inner.entries.contains_key(&key) {
277 self.evict_lru(&mut inner);
278 }
279
280 inner.lru_index.insert(access_order, key.clone());
281 inner.entries.insert(
282 key,
283 CacheEntry {
284 verdict: verdict.clone(),
285 inserted_at: Instant::now(),
286 generation: current_gen,
287 last_accessed: access_order,
288 },
289 );
290 self.insertions.fetch_add(1, Ordering::Relaxed);
291 }
292
293 pub fn insert(&self, action: &Action, context: Option<&EvaluationContext>, verdict: &Verdict) {
297 self.insert_with_risk(action, context, verdict, false);
298 }
299
300 pub fn invalidate(&self) {
305 self.policy_generation.fetch_add(1, Ordering::SeqCst);
306 self.invalidations.fetch_add(1, Ordering::Relaxed);
307 }
308
309 pub fn stats(&self) -> CacheStats {
311 CacheStats {
312 hits: self.hits.load(Ordering::Relaxed),
313 misses: self.misses.load(Ordering::Relaxed),
314 evictions: self.evictions.load(Ordering::Relaxed),
315 insertions: self.insertions.load(Ordering::Relaxed),
316 invalidations: self.invalidations.load(Ordering::Relaxed),
317 }
318 }
319
320 pub fn len(&self) -> usize {
324 self.inner.read().map(|c| c.entries.len()).unwrap_or(0)
325 }
326
327 pub fn is_empty(&self) -> bool {
331 self.len() == 0
332 }
333
334 fn is_cacheable_context(context: Option<&EvaluationContext>, has_risk_score: bool) -> bool {
347 if has_risk_score {
353 return false;
354 }
355 match context {
356 None => true,
357 Some(ctx) => {
358 ctx.timestamp.is_none()
372 && ctx.call_counts.is_empty()
373 && ctx.previous_actions.is_empty()
374 && ctx.call_chain.is_empty()
375 && ctx.capability_token.is_none()
376 && ctx.session_state.is_none()
377 && ctx.verification_tier.is_none()
378 }
379 }
380 }
381
382 fn build_key(action: &Action, context: Option<&EvaluationContext>) -> CacheKey {
390 CacheKey {
391 tool_hash: Self::hash_str(&crate::normalize::normalize_full(&action.tool)),
392 function_hash: Self::hash_str(&crate::normalize::normalize_full(&action.function)),
393 paths_hash: Self::hash_sorted_normalized_strs(&action.target_paths),
396 domains_hash: Self::hash_sorted_normalized_strs(&action.target_domains),
397 resolved_ips_hash: Self::hash_sorted_strs(&action.resolved_ips),
398 identity_hash: Self::hash_identity(context),
399 parameters_hash: Self::hash_parameters(&action.parameters),
404 }
405 }
406
407 fn hash_str(s: &str) -> u64 {
409 let mut hasher = std::collections::hash_map::DefaultHasher::new();
410 s.hash(&mut hasher);
411 hasher.finish()
412 }
413
414 fn hash_sorted_strs(strs: &[String]) -> u64 {
419 let mut hasher = std::collections::hash_map::DefaultHasher::new();
420 let mut sorted: Vec<&str> = strs.iter().map(|s| s.as_str()).collect();
421 sorted.sort_unstable();
422 sorted.len().hash(&mut hasher);
423 for s in &sorted {
424 s.hash(&mut hasher);
425 }
426 hasher.finish()
427 }
428
429 fn hash_sorted_normalized_strs(strs: &[String]) -> u64 {
435 let mut hasher = std::collections::hash_map::DefaultHasher::new();
436 let mut normalized: Vec<String> = strs
437 .iter()
438 .map(|s| crate::normalize::normalize_full(s))
439 .collect();
440 normalized.sort_unstable();
441 normalized.len().hash(&mut hasher);
442 for s in &normalized {
443 s.hash(&mut hasher);
444 }
445 hasher.finish()
446 }
447
448 fn hash_parameters(params: &serde_json::Value) -> u64 {
456 let mut hasher = std::collections::hash_map::DefaultHasher::new();
457 match serde_json::to_string(params) {
461 Ok(json) => json.hash(&mut hasher),
462 Err(_) => 255u8.hash(&mut hasher),
463 }
464 hasher.finish()
465 }
466
467 fn hash_identity(context: Option<&EvaluationContext>) -> u64 {
472 let mut hasher = std::collections::hash_map::DefaultHasher::new();
473 match context {
474 None => {
475 0u8.hash(&mut hasher); }
477 Some(ctx) => {
478 1u8.hash(&mut hasher); ctx.agent_id.hash(&mut hasher);
484 ctx.tenant_id.hash(&mut hasher);
485 if let Some(ref identity) = ctx.agent_identity {
486 2u8.hash(&mut hasher); identity.issuer.hash(&mut hasher);
488 identity.subject.hash(&mut hasher);
489 identity.audience.len().hash(&mut hasher);
494 for aud in &identity.audience {
495 aud.hash(&mut hasher);
496 }
497 let mut claim_keys: Vec<&String> = identity.claims.keys().collect();
499 claim_keys.sort_unstable();
500 claim_keys.len().hash(&mut hasher);
501 for key in &claim_keys {
502 key.hash(&mut hasher);
503 if let Ok(val_str) = serde_json::to_string(&identity.claims[*key]) {
505 val_str.hash(&mut hasher);
506 }
507 }
508 } else {
509 3u8.hash(&mut hasher); }
511 }
512 }
513 hasher.finish()
514 }
515
516 fn evict_lru(&self, inner: &mut CacheInner) {
522 if let Some((&access_counter, _)) = inner.lru_index.iter().next() {
524 if let Some(evicted_key) = inner.lru_index.remove(&access_counter) {
525 inner.entries.remove(&evicted_key);
526 self.evictions.fetch_add(1, Ordering::Relaxed);
527 }
528 }
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use serde_json::json;
536 use std::collections::HashMap;
537 use std::thread;
538 use vellaveto_types::EvaluationContext;
539
540 fn make_action(tool: &str, function: &str) -> Action {
542 Action::new(tool.to_string(), function.to_string(), json!({}))
543 }
544
545 fn make_action_with_targets(
547 tool: &str,
548 function: &str,
549 paths: Vec<&str>,
550 domains: Vec<&str>,
551 ) -> Action {
552 Action {
553 tool: tool.to_string(),
554 function: function.to_string(),
555 parameters: json!({}),
556 target_paths: paths.into_iter().map(|s| s.to_string()).collect(),
557 target_domains: domains.into_iter().map(|s| s.to_string()).collect(),
558 resolved_ips: vec![],
559 }
560 }
561
562 fn make_cacheable_context(agent_id: &str) -> EvaluationContext {
564 EvaluationContext {
565 agent_id: Some(agent_id.to_string()),
566 tenant_id: None,
567 timestamp: None,
568 agent_identity: None,
569 call_counts: HashMap::new(),
570 previous_actions: vec![],
571 call_chain: vec![],
572 verification_tier: None,
573 capability_token: None,
574 session_state: None,
575 }
576 }
577
578 fn make_noncacheable_context() -> EvaluationContext {
580 let mut counts = HashMap::new();
581 counts.insert("bash".to_string(), 5);
582 EvaluationContext {
583 agent_id: Some("agent-1".to_string()),
584 tenant_id: None,
585 timestamp: None,
586 agent_identity: None,
587 call_counts: counts,
588 previous_actions: vec!["read_file".to_string()],
589 call_chain: vec![],
590 verification_tier: None,
591 capability_token: None,
592 session_state: None,
593 }
594 }
595
596 #[test]
597 fn test_cache_hit_and_miss() {
598 let cache = DecisionCache::new(100, Duration::from_secs(60));
599 let action = make_action("read_file", "read");
600 let verdict = Verdict::Allow;
601
602 assert!(cache.get(&action, None).is_none());
604 assert_eq!(cache.stats().misses, 1);
605
606 cache.insert(&action, None, &verdict);
608 let result = cache.get(&action, None);
609 assert!(result.is_some());
610 assert_eq!(result, Some(Verdict::Allow));
611 assert_eq!(cache.stats().hits, 1);
612 assert_eq!(cache.stats().insertions, 1);
613
614 let other_action = make_action("write_file", "write");
616 assert!(cache.get(&other_action, None).is_none());
617 assert_eq!(cache.stats().misses, 2);
618 }
619
620 #[test]
621 fn test_ttl_expiry() {
622 let cache = DecisionCache::new(100, Duration::from_secs(1));
624 let action = make_action("read_file", "read");
625 let verdict = Verdict::Allow;
626
627 cache.insert(&action, None, &verdict);
628 assert!(cache.get(&action, None).is_some());
629
630 thread::sleep(Duration::from_millis(1100));
632
633 assert!(cache.get(&action, None).is_none());
635 }
636
637 #[test]
638 fn test_invalidation_on_policy_change() {
639 let cache = DecisionCache::new(100, Duration::from_secs(60));
640 let action = make_action("read_file", "read");
641 let verdict = Verdict::Allow;
642
643 cache.insert(&action, None, &verdict);
644 assert!(cache.get(&action, None).is_some());
645
646 cache.invalidate();
648 assert_eq!(cache.stats().invalidations, 1);
649
650 assert!(cache.get(&action, None).is_none());
652
653 let deny = Verdict::Deny {
655 reason: "blocked".to_string(),
656 };
657 cache.insert(&action, None, &deny);
658 let result = cache.get(&action, None);
659 assert!(matches!(result, Some(Verdict::Deny { .. })));
660 }
661
662 #[test]
663 fn test_lru_eviction() {
664 let cache = DecisionCache::new(3, Duration::from_secs(60));
665
666 for i in 0..3 {
668 let action = make_action(&format!("tool_{i}"), "func");
669 cache.insert(&action, None, &Verdict::Allow);
670 }
671 assert_eq!(cache.len(), 3);
672 assert_eq!(cache.stats().evictions, 0);
673
674 let action_new = make_action("tool_new", "func");
676 cache.insert(&action_new, None, &Verdict::Allow);
677 assert_eq!(cache.len(), 3);
678 assert_eq!(cache.stats().evictions, 1);
679
680 assert!(cache.get(&action_new, None).is_some());
682
683 let action_0 = make_action("tool_0", "func");
685 assert!(cache.get(&action_0, None).is_none());
686 }
687
688 #[test]
689 fn test_stats_tracking() {
690 let cache = DecisionCache::new(100, Duration::from_secs(60));
691 let action = make_action("read_file", "read");
692
693 let stats = cache.stats();
695 assert_eq!(stats.hits, 0);
696 assert_eq!(stats.misses, 0);
697 assert_eq!(stats.evictions, 0);
698 assert_eq!(stats.insertions, 0);
699 assert_eq!(stats.invalidations, 0);
700
701 cache.get(&action, None);
703 assert_eq!(cache.stats().misses, 1);
704
705 cache.insert(&action, None, &Verdict::Allow);
707 assert_eq!(cache.stats().insertions, 1);
708
709 cache.get(&action, None);
711 assert_eq!(cache.stats().hits, 1);
712
713 cache.invalidate();
715 assert_eq!(cache.stats().invalidations, 1);
716 }
717
718 #[test]
719 fn test_context_dependent_not_cached() {
720 let cache = DecisionCache::new(100, Duration::from_secs(60));
721 let action = make_action("bash", "execute");
722 let verdict = Verdict::Allow;
723 let ctx = make_noncacheable_context();
724
725 cache.insert(&action, Some(&ctx), &verdict);
727 assert_eq!(cache.len(), 0);
728 assert_eq!(cache.stats().insertions, 0);
729
730 assert!(cache.get(&action, Some(&ctx)).is_none());
732 assert_eq!(cache.stats().misses, 1);
733 }
734
735 #[test]
736 fn test_context_dependent_timestamp_not_cached() {
737 let cache = DecisionCache::new(100, Duration::from_secs(60));
738 let action = make_action("bash", "execute");
739 let verdict = Verdict::Allow;
740
741 let ctx = EvaluationContext {
742 timestamp: Some("2026-01-01T12:00:00Z".to_string()),
743 agent_id: None,
744 tenant_id: None,
745 agent_identity: None,
746 call_counts: HashMap::new(),
747 previous_actions: vec![],
748 call_chain: vec![],
749 verification_tier: None,
750 capability_token: None,
751 session_state: None,
752 };
753
754 cache.insert(&action, Some(&ctx), &verdict);
755 assert_eq!(cache.len(), 0);
756 }
757
758 #[test]
759 fn test_context_dependent_session_state_not_cached() {
760 let cache = DecisionCache::new(100, Duration::from_secs(60));
761 let action = make_action("bash", "execute");
762 let verdict = Verdict::Allow;
763
764 let ctx = EvaluationContext {
765 session_state: Some("active".to_string()),
766 agent_id: None,
767 tenant_id: None,
768 timestamp: None,
769 agent_identity: None,
770 call_counts: HashMap::new(),
771 previous_actions: vec![],
772 call_chain: vec![],
773 verification_tier: None,
774 capability_token: None,
775 };
776
777 cache.insert(&action, Some(&ctx), &verdict);
778 assert_eq!(cache.len(), 0);
779 }
780
781 #[test]
782 fn test_cacheable_context_with_identity() {
783 let cache = DecisionCache::new(100, Duration::from_secs(60));
784 let action = make_action("read_file", "read");
785 let verdict = Verdict::Allow;
786 let ctx = make_cacheable_context("agent-42");
787
788 cache.insert(&action, Some(&ctx), &verdict);
790 assert_eq!(cache.len(), 1);
791
792 let result = cache.get(&action, Some(&ctx));
793 assert_eq!(result, Some(Verdict::Allow));
794 }
795
796 #[test]
797 fn test_cache_key_collision_resistance() {
798 let cache = DecisionCache::new(100, Duration::from_secs(60));
799
800 let action_a = make_action("read_file", "execute");
802 let action_b = make_action("write_file", "execute");
803
804 cache.insert(&action_a, None, &Verdict::Allow);
805 cache.insert(
806 &action_b,
807 None,
808 &Verdict::Deny {
809 reason: "blocked".to_string(),
810 },
811 );
812
813 assert_eq!(cache.get(&action_a, None), Some(Verdict::Allow));
814 assert!(matches!(
815 cache.get(&action_b, None),
816 Some(Verdict::Deny { .. })
817 ));
818
819 let action_c = make_action_with_targets("read", "exec", vec!["/tmp/a"], vec![]);
821 let action_d = make_action_with_targets("read", "exec", vec!["/tmp/b"], vec![]);
822
823 cache.insert(&action_c, None, &Verdict::Allow);
824 cache.insert(
825 &action_d,
826 None,
827 &Verdict::Deny {
828 reason: "path denied".to_string(),
829 },
830 );
831
832 assert_eq!(cache.get(&action_c, None), Some(Verdict::Allow));
833 assert!(matches!(
834 cache.get(&action_d, None),
835 Some(Verdict::Deny { .. })
836 ));
837
838 let action_e = make_action_with_targets("http", "get", vec![], vec!["example.com"]);
840 let action_f = make_action_with_targets("http", "get", vec![], vec!["evil.com"]);
841
842 cache.insert(&action_e, None, &Verdict::Allow);
843 cache.insert(
844 &action_f,
845 None,
846 &Verdict::Deny {
847 reason: "domain denied".to_string(),
848 },
849 );
850
851 assert_eq!(cache.get(&action_e, None), Some(Verdict::Allow));
852 assert!(matches!(
853 cache.get(&action_f, None),
854 Some(Verdict::Deny { .. })
855 ));
856
857 let ctx_agent_1 = make_cacheable_context("agent-1");
859 let ctx_agent_2 = make_cacheable_context("agent-2");
860 let action_g = make_action("tool", "func");
861
862 cache.insert(&action_g, Some(&ctx_agent_1), &Verdict::Allow);
863 cache.insert(
864 &action_g,
865 Some(&ctx_agent_2),
866 &Verdict::Deny {
867 reason: "wrong agent".to_string(),
868 },
869 );
870
871 assert_eq!(
872 cache.get(&action_g, Some(&ctx_agent_1)),
873 Some(Verdict::Allow)
874 );
875 assert!(matches!(
876 cache.get(&action_g, Some(&ctx_agent_2)),
877 Some(Verdict::Deny { .. })
878 ));
879 }
880
881 #[test]
882 fn test_max_entries_bound() {
883 let cache = DecisionCache::new(MAX_CACHE_ENTRIES + 1000, Duration::from_secs(60));
885 assert_eq!(cache.max_entries, MAX_CACHE_ENTRIES);
886
887 let cache_min = DecisionCache::new(0, Duration::from_secs(60));
889 assert_eq!(cache_min.max_entries, 1);
890 }
891
892 #[test]
893 fn test_ttl_bounds_clamped() {
894 let cache = DecisionCache::new(100, Duration::from_secs(0));
896 assert_eq!(cache.ttl, Duration::from_secs(MIN_TTL_SECS));
897
898 let cache_max = DecisionCache::new(100, Duration::from_secs(MAX_TTL_SECS + 1000));
900 assert_eq!(cache_max.ttl, Duration::from_secs(MAX_TTL_SECS));
901 }
902
903 #[test]
904 fn test_is_empty() {
905 let cache = DecisionCache::new(100, Duration::from_secs(60));
906 assert!(cache.is_empty());
907
908 let action = make_action("tool", "func");
909 cache.insert(&action, None, &Verdict::Allow);
910 assert!(!cache.is_empty());
911 }
912
913 #[test]
914 fn test_deny_verdict_cached() {
915 let cache = DecisionCache::new(100, Duration::from_secs(60));
916 let action = make_action("bash", "execute");
917 let verdict = Verdict::Deny {
918 reason: "dangerous tool".to_string(),
919 };
920
921 cache.insert(&action, None, &verdict);
922 let result = cache.get(&action, None);
923 assert!(matches!(result, Some(Verdict::Deny { ref reason }) if reason == "dangerous tool"));
924 }
925
926 #[test]
927 fn test_require_approval_verdict_cached() {
928 let cache = DecisionCache::new(100, Duration::from_secs(60));
929 let action = make_action("deploy", "production");
930 let verdict = Verdict::RequireApproval {
931 reason: "needs human review".to_string(),
932 };
933
934 cache.insert(&action, None, &verdict);
935 let result = cache.get(&action, None);
936 assert!(
937 matches!(result, Some(Verdict::RequireApproval { ref reason }) if reason == "needs human review")
938 );
939 }
940
941 #[test]
942 fn test_overwrite_existing_entry() {
943 let cache = DecisionCache::new(100, Duration::from_secs(60));
944 let action = make_action("tool", "func");
945
946 cache.insert(&action, None, &Verdict::Allow);
947 assert_eq!(cache.get(&action, None), Some(Verdict::Allow));
948
949 let deny = Verdict::Deny {
951 reason: "now denied".to_string(),
952 };
953 cache.insert(&action, None, &deny);
954 assert!(matches!(
955 cache.get(&action, None),
956 Some(Verdict::Deny { .. })
957 ));
958
959 assert_eq!(cache.len(), 1);
961 }
962
963 #[test]
964 fn test_path_order_independence() {
965 let cache = DecisionCache::new(100, Duration::from_secs(60));
966
967 let action_a = make_action_with_targets("read", "exec", vec!["/a", "/b"], vec![]);
969 let action_b = make_action_with_targets("read", "exec", vec!["/b", "/a"], vec![]);
970
971 cache.insert(&action_a, None, &Verdict::Allow);
972 assert_eq!(cache.get(&action_b, None), Some(Verdict::Allow));
974 }
975
976 #[test]
977 fn test_domain_order_independence() {
978 let cache = DecisionCache::new(100, Duration::from_secs(60));
979
980 let action_a = make_action_with_targets("http", "get", vec![], vec!["a.com", "b.com"]);
981 let action_b = make_action_with_targets("http", "get", vec![], vec!["b.com", "a.com"]);
982
983 cache.insert(&action_a, None, &Verdict::Allow);
984 assert_eq!(cache.get(&action_b, None), Some(Verdict::Allow));
985 }
986
987 #[test]
988 fn test_multiple_invalidations() {
989 let cache = DecisionCache::new(100, Duration::from_secs(60));
990 let action = make_action("tool", "func");
991
992 cache.insert(&action, None, &Verdict::Allow);
993 cache.invalidate();
994 cache.invalidate();
995 cache.invalidate();
996
997 assert_eq!(cache.stats().invalidations, 3);
998 assert!(cache.get(&action, None).is_none());
999
1000 cache.insert(&action, None, &Verdict::Allow);
1002 assert!(cache.get(&action, None).is_some());
1003 }
1004
1005 #[test]
1006 fn test_debug_does_not_leak_entries() {
1007 let cache = DecisionCache::new(100, Duration::from_secs(60));
1008 let action = make_action("secret_tool", "func");
1009 cache.insert(&action, None, &Verdict::Allow);
1010
1011 let debug_output = format!("{cache:?}");
1012 assert!(debug_output.contains("max_entries"));
1014 assert!(debug_output.contains("current_size"));
1015 assert!(!debug_output.contains("secret_tool"));
1016 }
1017
1018 #[test]
1020 fn test_r227_cache_key_case_insensitive() {
1021 let cache = DecisionCache::new(100, Duration::from_secs(60));
1022 let action_lower = make_action("file_read", "get_content");
1023 let action_upper = make_action("File_Read", "Get_Content");
1024 let action_mixed = make_action("FILE_READ", "GET_CONTENT");
1025
1026 cache.insert(&action_lower, None, &Verdict::Allow);
1027
1028 assert!(
1030 cache.get(&action_upper, None).is_some(),
1031 "Mixed-case tool name should match lowercased cache key"
1032 );
1033 assert!(
1034 cache.get(&action_mixed, None).is_some(),
1035 "All-caps tool name should match lowercased cache key"
1036 );
1037 }
1038
1039 #[test]
1043 fn test_r228_resolved_ips_in_cache_key() {
1044 let cache = DecisionCache::new(100, Duration::from_secs(60));
1045
1046 let mut action_public = Action::new("tool", "fn", serde_json::json!({}));
1047 action_public.target_domains.push("attacker.com".into());
1048 action_public.resolved_ips.push("1.2.3.4".into());
1049
1050 let mut action_metadata = Action::new("tool", "fn", serde_json::json!({}));
1051 action_metadata.target_domains.push("attacker.com".into());
1052 action_metadata.resolved_ips.push("169.254.169.254".into());
1053
1054 cache.insert(&action_public, None, &Verdict::Allow);
1056
1057 let result = cache.get(&action_metadata, None);
1059 assert!(
1060 result.is_none(),
1061 "Different resolved IP must produce a cache miss (DNS rebinding defense)"
1062 );
1063 }
1064
1065 #[test]
1070 fn test_r237_risk_score_prevents_caching() {
1071 let cache = DecisionCache::new(100, Duration::from_secs(60));
1072 let action = make_action("tool", "func");
1073
1074 cache.insert_with_risk(&action, None, &Verdict::Allow, true);
1076 assert_eq!(cache.len(), 0, "Insert with risk_score should be a no-op");
1077 assert_eq!(cache.stats().insertions, 0);
1078
1079 cache.insert(&action, None, &Verdict::Allow);
1081 assert_eq!(cache.len(), 1);
1082 assert!(
1083 cache.get_with_risk(&action, None, true).is_none(),
1084 "Get with risk_score should bypass cache"
1085 );
1086 assert!(
1087 cache.get_with_risk(&action, None, false).is_some(),
1088 "Get without risk_score should hit cache"
1089 );
1090 }
1091
1092 #[test]
1094 fn test_r237_backward_compat_no_risk_score() {
1095 let cache = DecisionCache::new(100, Duration::from_secs(60));
1096 let action = make_action("tool", "func");
1097
1098 cache.insert(&action, None, &Verdict::Allow);
1099 assert_eq!(cache.len(), 1);
1100 assert!(cache.get(&action, None).is_some());
1101 }
1102
1103 #[test]
1108 fn test_r245_parameters_in_cache_key() {
1109 let cache = DecisionCache::new(100, Duration::from_secs(60));
1110
1111 let action_safe = Action::new(
1112 "read_file",
1113 "exec",
1114 serde_json::json!({"path": "/tmp/safe"}),
1115 );
1116 let action_malicious = Action::new(
1117 "read_file",
1118 "exec",
1119 serde_json::json!({"path": "/tmp/safe", "inject": "<script>alert(1)</script>"}),
1120 );
1121
1122 cache.insert(&action_safe, None, &Verdict::Allow);
1124 assert_eq!(cache.len(), 1);
1125
1126 let result = cache.get(&action_malicious, None);
1128 assert!(
1129 result.is_none(),
1130 "Different parameters must produce a cache miss (verdict poisoning defense)"
1131 );
1132 }
1133
1134 #[test]
1136 fn test_r245_same_parameters_cache_hit() {
1137 let cache = DecisionCache::new(100, Duration::from_secs(60));
1138
1139 let action1 = Action::new("tool", "fn", serde_json::json!({"key": "value"}));
1140 let action2 = Action::new("tool", "fn", serde_json::json!({"key": "value"}));
1141
1142 cache.insert(&action1, None, &Verdict::Allow);
1143 assert!(
1144 cache.get(&action2, None).is_some(),
1145 "Identical parameters must produce a cache hit"
1146 );
1147 }
1148}