1use chrono::Utc;
13use serde_json::{json, Value};
14use vellaveto_engine::PolicyEngine;
15use vellaveto_types::{Action, EvaluationContext, Policy, Verdict};
16
17use super::auth::OAuthValidationEvidence;
18use super::{HmacSha256, X_UPSTREAM_AGENTS};
19use crate::oauth::OAuthClaims;
20use crate::session::SessionStore;
21use hmac::Mac;
22
23use axum::http::HeaderMap;
24
25pub const MAX_ACTION_HISTORY: usize = 100;
27
28pub const MAX_CALL_COUNT_TOOLS: usize = 1024;
31
32pub const MAX_PENDING_TOOL_CALLS: usize = 256;
35
36const MAX_JSONRPC_ID_KEY_LEN: usize = 256;
39
40pub trait OAuthAuditClaims {
41 fn oauth_subject(&self) -> &str;
42 fn oauth_scope(&self) -> &str;
43}
44
45impl OAuthAuditClaims for OAuthClaims {
46 fn oauth_subject(&self) -> &str {
47 &self.sub
48 }
49
50 fn oauth_scope(&self) -> &str {
51 &self.scope
52 }
53}
54
55impl OAuthAuditClaims for OAuthValidationEvidence {
56 fn oauth_subject(&self) -> &str {
57 &self.claims.sub
58 }
59
60 fn oauth_scope(&self) -> &str {
61 &self.claims.scope
62 }
63}
64
65pub fn jsonrpc_id_key(id: &Value) -> Option<String> {
67 match id {
68 Value::String(s) if s.len() <= MAX_JSONRPC_ID_KEY_LEN => Some(format!("s:{s}")),
69 Value::Number(n) => {
70 let n_str = n.to_string();
71 if n_str.len() <= MAX_JSONRPC_ID_KEY_LEN {
72 Some(format!("n:{n_str}"))
73 } else {
74 None
75 }
76 }
77 _ => None,
78 }
79}
80
81pub fn track_pending_tool_call(
83 sessions: &SessionStore,
84 session_id: &str,
85 request_id: &Value,
86 tool_name: &str,
87) {
88 let Some(id_key) = jsonrpc_id_key(request_id) else {
89 return;
90 };
91 if let Some(mut session) = sessions.get_mut(session_id) {
92 if session.pending_tool_calls.len() >= MAX_PENDING_TOOL_CALLS {
94 if let Some(oldest_key) = session.pending_tool_calls.keys().next().cloned() {
95 session.pending_tool_calls.remove(&oldest_key);
96 }
97 }
98 session
99 .pending_tool_calls
100 .insert(id_key, tool_name.to_string());
101 }
102}
103
104pub fn take_tracked_tool_call(
106 sessions: &SessionStore,
107 session_id: &str,
108 response_id: Option<&Value>,
109) -> Option<String> {
110 let id_key = response_id.and_then(jsonrpc_id_key)?;
111 sessions
112 .get_mut(session_id)
113 .and_then(|mut s| s.pending_tool_calls.remove(&id_key))
114}
115
116pub fn build_evaluation_context(
118 sessions: &SessionStore,
119 session_id: &str,
120) -> Option<EvaluationContext> {
121 sessions
122 .get_mut(session_id)
123 .map(|session| EvaluationContext {
124 timestamp: None, agent_id: session.oauth_subject.clone(),
126 agent_identity: session.agent_identity.clone(),
127 call_counts: session.call_counts.clone(),
128 previous_actions: session.action_history.iter().cloned().collect(),
129 call_chain: session.current_call_chain.clone(),
130 tenant_id: None,
131 verification_tier: None,
132 capability_token: None,
133 session_state: None,
134 })
135}
136
137pub fn build_audit_context<T: OAuthAuditClaims>(
139 session_id: &str,
140 extra: Value,
141 oauth_claims: &Option<T>,
142) -> Value {
143 let mut ctx = json!({"source": "http_proxy", "session": session_id});
144 if let Value::Object(map) = extra {
145 if let Value::Object(ref mut ctx_map) = ctx {
146 for (k, v) in map {
147 ctx_map.insert(k, v);
148 }
149 }
150 }
151 if let Some(claims) = oauth_claims {
152 if let Value::Object(ref mut ctx_map) = ctx {
153 ctx_map.insert("oauth_subject".to_string(), json!(claims.oauth_subject()));
154 if !claims.oauth_scope().is_empty() {
155 ctx_map.insert("oauth_scopes".to_string(), json!(claims.oauth_scope()));
156 }
157 }
158 }
159 ctx
160}
161
162pub fn build_audit_context_with_chain<T: OAuthAuditClaims>(
164 session_id: &str,
165 extra: Value,
166 oauth_claims: &Option<T>,
167 call_chain: &[vellaveto_types::CallChainEntry],
168) -> Value {
169 let mut ctx = build_audit_context(session_id, extra, oauth_claims);
170 if !call_chain.is_empty() {
171 if let Value::Object(ref mut ctx_map) = ctx {
172 ctx_map.insert(
173 "call_chain".to_string(),
174 serde_json::to_value(call_chain).unwrap_or_else(|e| {
176 tracing::warn!("call_chain serialization failed: {e}");
177 Value::Null
178 }),
179 );
180 }
181 }
182 ctx
183}
184
185pub fn validate_call_chain_header(
191 headers: &HeaderMap,
192 limits: &vellaveto_config::LimitsConfig,
193) -> Result<(), &'static str> {
194 let raw_header = match headers.get(X_UPSTREAM_AGENTS) {
195 Some(v) => v,
196 None => return Ok(()),
197 };
198
199 let raw_str = raw_header
200 .to_str()
201 .map_err(|_| "X-Upstream-Agents header is not valid UTF-8")?;
202 if raw_str.len() > limits.max_call_chain_header_bytes {
203 return Err("X-Upstream-Agents header exceeds size limit");
204 }
205
206 let entries = serde_json::from_str::<Vec<vellaveto_types::CallChainEntry>>(raw_str)
207 .map_err(|_| "X-Upstream-Agents header is not valid JSON array")?;
208 if entries.len() > limits.max_call_chain_length {
209 return Err("X-Upstream-Agents header exceeds entry limit");
210 }
211 Ok(())
212}
213
214pub fn extract_call_chain_from_headers(
227 headers: &HeaderMap,
228 hmac_key: Option<&[u8; 32]>,
229 limits: &vellaveto_config::LimitsConfig,
230) -> Vec<vellaveto_types::CallChainEntry> {
231 if let Err(reason) = validate_call_chain_header(headers, limits) {
232 tracing::warn!(
233 reason = reason,
234 "Call chain header validation failed during extraction; dropping upstream chain"
235 );
236 return Vec::new();
237 }
238
239 let max_age_secs = i64::try_from(limits.call_chain_max_age_secs).unwrap_or(i64::MAX);
241
242 let mut entries = match headers.get(X_UPSTREAM_AGENTS) {
243 Some(raw_header) => {
244 let raw_str = match raw_header.to_str() {
245 Ok(raw_str) => raw_str,
246 Err(_) => {
247 tracing::warn!(
248 "Call chain header became non UTF-8 after validation; dropping upstream chain"
249 );
250 return Vec::new();
251 }
252 };
253 match serde_json::from_str::<Vec<vellaveto_types::CallChainEntry>>(raw_str) {
254 Ok(parsed) => parsed,
255 Err(error) => {
256 tracing::warn!(
257 error = %error,
258 "Call chain header became non-JSON after validation; dropping upstream chain"
259 );
260 return Vec::new();
261 }
262 }
263 }
264 None => Vec::new(),
265 };
266
267 let now = Utc::now();
270 if let Some(key) = hmac_key {
271 for entry in &mut entries {
272 let timestamp_valid = chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
274 .map(|ts| (now - ts.with_timezone(&Utc)).num_seconds() <= max_age_secs)
275 .unwrap_or(false);
276
277 if !timestamp_valid {
278 tracing::warn!(
279 agent_id = %entry.agent_id,
280 tool = %entry.tool,
281 timestamp = %entry.timestamp,
282 "IMPROVEMENT_PLAN 2.1: Call chain entry has stale timestamp — marking as unverified"
283 );
284 entry.verified = Some(false);
285 entry.agent_id = format!("[stale] {}", entry.agent_id);
286 continue;
287 }
288
289 match &entry.hmac {
290 Some(hmac_hex) => {
291 let content = call_chain_entry_signing_content(entry);
292 match verify_call_chain_hmac(key, &content, hmac_hex) {
293 Ok(true) => {
294 entry.verified = Some(true);
295 }
296 _ => {
297 tracing::warn!(
299 agent_id = %entry.agent_id,
300 tool = %entry.tool,
301 "FIND-015: Call chain entry has invalid HMAC — marking as unverified"
302 );
303 entry.verified = Some(false);
304 entry.agent_id = format!("[unverified] {}", entry.agent_id);
305 }
306 }
307 }
308 None => {
309 tracing::warn!(
311 agent_id = %entry.agent_id,
312 tool = %entry.tool,
313 "FIND-015: Call chain entry has no HMAC tag — marking as unverified"
314 );
315 entry.verified = Some(false);
316 entry.agent_id = format!("[unverified] {}", entry.agent_id);
317 }
318 }
319 }
320 }
321
322 entries
323}
324
325pub fn sync_session_call_chain_from_headers(
331 sessions: &SessionStore,
332 session_id: &str,
333 headers: &HeaderMap,
334 hmac_key: Option<&[u8; 32]>,
335 limits: &vellaveto_config::LimitsConfig,
336) -> Vec<vellaveto_types::CallChainEntry> {
337 let upstream_chain = extract_call_chain_from_headers(headers, hmac_key, limits);
338 if let Some(mut session) = sessions.get_mut(session_id) {
339 session.current_call_chain = upstream_chain.clone();
340 }
341 upstream_chain
342}
343
344pub fn build_current_agent_entry(
352 agent_id: Option<&str>,
353 tool: &str,
354 function: &str,
355 hmac_key: Option<&[u8; 32]>,
356) -> vellaveto_types::CallChainEntry {
357 let mut entry = vellaveto_types::CallChainEntry {
358 agent_id: agent_id.unwrap_or("unknown").to_string(),
359 tool: tool.to_string(),
360 function: function.to_string(),
361 timestamp: Utc::now().to_rfc3339(),
362 hmac: None,
363 verified: None,
364 };
365
366 if let Some(key) = hmac_key {
368 let content = call_chain_entry_signing_content(&entry);
369 if let Ok(hmac_hex) = compute_call_chain_hmac(key, &content) {
370 entry.hmac = Some(hmac_hex);
371 entry.verified = Some(true);
372 }
373 }
374
375 entry
376}
377
378pub fn call_chain_entry_signing_content(entry: &vellaveto_types::CallChainEntry) -> Vec<u8> {
386 let agent_id = entry
389 .agent_id
390 .strip_prefix("[unverified] ")
391 .or_else(|| entry.agent_id.strip_prefix("[stale] "))
392 .unwrap_or(&entry.agent_id);
393
394 let mut content = Vec::new();
396 for field in &[
397 agent_id,
398 entry.tool.as_str(),
399 entry.function.as_str(),
400 entry.timestamp.as_str(),
401 ] {
402 content.extend_from_slice(&(field.len() as u64).to_le_bytes());
403 content.extend_from_slice(field.as_bytes());
404 }
405 content
406}
407
408#[allow(clippy::result_unit_err)]
411pub fn compute_call_chain_hmac(key: &[u8; 32], data: &[u8]) -> Result<String, ()> {
412 let mut mac = HmacSha256::new_from_slice(key).map_err(|_| ())?;
413 mac.update(data);
414 let result = mac.finalize();
415 Ok(hex::encode(result.into_bytes()))
416}
417
418#[allow(clippy::result_unit_err)]
421pub fn verify_call_chain_hmac(key: &[u8; 32], data: &[u8], expected_hex: &str) -> Result<bool, ()> {
422 let expected_bytes = match hex::decode(expected_hex) {
423 Ok(b) => b,
424 Err(_) => return Ok(false),
425 };
426 let mut mac = HmacSha256::new_from_slice(key).map_err(|_| ())?;
427 mac.update(data);
428 Ok(mac.verify_slice(&expected_bytes).is_ok())
429}
430
431#[derive(Debug)]
433pub struct PrivilegeEscalationCheck {
434 pub escalation_detected: bool,
436 pub escalating_from_agent: Option<String>,
438 pub upstream_deny_reason: Option<String>,
440}
441
442#[allow(deprecated)] pub fn check_privilege_escalation(
455 engine: &PolicyEngine,
456 policies: &[Policy],
457 action: &Action,
458 call_chain: &[vellaveto_types::CallChainEntry],
459 current_agent_id: Option<&str>,
460) -> PrivilegeEscalationCheck {
461 if call_chain.is_empty() {
463 return PrivilegeEscalationCheck {
464 escalation_detected: false,
465 escalating_from_agent: None,
466 upstream_deny_reason: None,
467 };
468 }
469
470 for entry in call_chain {
472 if current_agent_id == Some(entry.agent_id.as_str()) {
474 continue;
475 }
476
477 let upstream_ctx = EvaluationContext {
479 timestamp: None,
480 agent_id: Some(entry.agent_id.clone()),
481 agent_identity: None, call_counts: std::collections::HashMap::new(), previous_actions: Vec::new(),
484 call_chain: Vec::new(), tenant_id: None,
486 verification_tier: None,
487 capability_token: None,
488 session_state: None,
489 };
490
491 match engine.evaluate_action_with_context(action, policies, Some(&upstream_ctx)) {
493 Ok(Verdict::Deny { reason }) => {
494 tracing::warn!(
496 "SECURITY: Privilege escalation detected! Agent '{}' would be denied: {}, \
497 but action is being executed through current agent",
498 entry.agent_id,
499 reason
500 );
501 return PrivilegeEscalationCheck {
502 escalation_detected: true,
503 escalating_from_agent: Some(entry.agent_id.clone()),
504 upstream_deny_reason: Some(reason),
505 };
506 }
507 _ => {
508 }
510 }
511 }
512
513 PrivilegeEscalationCheck {
514 escalation_detected: false,
515 escalating_from_agent: None,
516 upstream_deny_reason: None,
517 }
518}
519
520#[cfg(test)]
521#[allow(clippy::field_reassign_with_default)]
522mod tests {
523 use super::*;
524 use serde_json::json;
525
526 fn test_key(fill: u8) -> [u8; 32] {
529 [fill; 32]
530 }
531
532 #[test]
537 fn test_jsonrpc_id_key_string_value() {
538 let id = json!("request-1");
539 assert_eq!(jsonrpc_id_key(&id), Some("s:request-1".to_string()));
540 }
541
542 #[test]
543 fn test_jsonrpc_id_key_number_value() {
544 let id = json!(42);
545 assert_eq!(jsonrpc_id_key(&id), Some("n:42".to_string()));
546 }
547
548 #[test]
549 fn test_jsonrpc_id_key_null_returns_none() {
550 let id = json!(null);
551 assert_eq!(jsonrpc_id_key(&id), None);
552 }
553
554 #[test]
555 fn test_jsonrpc_id_key_bool_returns_none() {
556 let id = json!(true);
557 assert_eq!(jsonrpc_id_key(&id), None);
558 }
559
560 #[test]
561 fn test_jsonrpc_id_key_object_returns_none() {
562 let id = json!({"id": 1});
563 assert_eq!(jsonrpc_id_key(&id), None);
564 }
565
566 #[test]
567 fn test_jsonrpc_id_key_array_returns_none() {
568 let id = json!([1, 2, 3]);
569 assert_eq!(jsonrpc_id_key(&id), None);
570 }
571
572 #[test]
573 fn test_jsonrpc_id_key_oversized_string_returns_none() {
574 let long_string = "x".repeat(MAX_JSONRPC_ID_KEY_LEN + 1);
575 let id = Value::String(long_string);
576 assert_eq!(jsonrpc_id_key(&id), None);
577 }
578
579 #[test]
580 fn test_jsonrpc_id_key_exact_max_length_string_accepted() {
581 let exact_string = "x".repeat(MAX_JSONRPC_ID_KEY_LEN);
582 let id = Value::String(exact_string.clone());
583 assert_eq!(jsonrpc_id_key(&id), Some(format!("s:{exact_string}")));
584 }
585
586 #[test]
587 fn test_jsonrpc_id_key_negative_number() {
588 let id = json!(-7);
589 assert_eq!(jsonrpc_id_key(&id), Some("n:-7".to_string()));
590 }
591
592 #[test]
593 fn test_jsonrpc_id_key_float_number() {
594 let id = json!(2.72);
595 assert_eq!(jsonrpc_id_key(&id), Some("n:2.72".to_string()));
596 }
597
598 #[test]
603 fn test_signing_content_deterministic() {
604 let entry = vellaveto_types::CallChainEntry {
605 agent_id: "agent-1".to_string(),
606 tool: "read_file".to_string(),
607 function: "execute".to_string(),
608 timestamp: "2026-01-01T00:00:00Z".to_string(),
609 hmac: None,
610 verified: None,
611 };
612 let content1 = call_chain_entry_signing_content(&entry);
613 let content2 = call_chain_entry_signing_content(&entry);
614 assert_eq!(content1, content2);
615 }
616
617 #[test]
618 fn test_signing_content_strips_unverified_prefix() {
619 let entry_clean = vellaveto_types::CallChainEntry {
620 agent_id: "agent-1".to_string(),
621 tool: "read_file".to_string(),
622 function: "execute".to_string(),
623 timestamp: "2026-01-01T00:00:00Z".to_string(),
624 hmac: None,
625 verified: None,
626 };
627 let entry_prefixed = vellaveto_types::CallChainEntry {
628 agent_id: "[unverified] agent-1".to_string(),
629 ..entry_clean.clone()
630 };
631 assert_eq!(
632 call_chain_entry_signing_content(&entry_clean),
633 call_chain_entry_signing_content(&entry_prefixed)
634 );
635 }
636
637 #[test]
638 fn test_signing_content_strips_stale_prefix() {
639 let entry_clean = vellaveto_types::CallChainEntry {
640 agent_id: "agent-1".to_string(),
641 tool: "read_file".to_string(),
642 function: "execute".to_string(),
643 timestamp: "2026-01-01T00:00:00Z".to_string(),
644 hmac: None,
645 verified: None,
646 };
647 let entry_stale = vellaveto_types::CallChainEntry {
648 agent_id: "[stale] agent-1".to_string(),
649 ..entry_clean.clone()
650 };
651 assert_eq!(
652 call_chain_entry_signing_content(&entry_clean),
653 call_chain_entry_signing_content(&entry_stale)
654 );
655 }
656
657 #[test]
658 fn test_signing_content_different_agents_differ() {
659 let entry1 = vellaveto_types::CallChainEntry {
660 agent_id: "agent-1".to_string(),
661 tool: "read_file".to_string(),
662 function: "execute".to_string(),
663 timestamp: "2026-01-01T00:00:00Z".to_string(),
664 hmac: None,
665 verified: None,
666 };
667 let entry2 = vellaveto_types::CallChainEntry {
668 agent_id: "agent-2".to_string(),
669 ..entry1.clone()
670 };
671 assert_ne!(
672 call_chain_entry_signing_content(&entry1),
673 call_chain_entry_signing_content(&entry2)
674 );
675 }
676
677 #[test]
678 fn test_signing_content_length_prefixed_no_boundary_confusion() {
679 let entry1 = vellaveto_types::CallChainEntry {
682 agent_id: "ab".to_string(),
683 tool: "cd".to_string(),
684 function: "execute".to_string(),
685 timestamp: "2026-01-01T00:00:00Z".to_string(),
686 hmac: None,
687 verified: None,
688 };
689 let entry2 = vellaveto_types::CallChainEntry {
690 agent_id: "a".to_string(),
691 tool: "bcd".to_string(),
692 function: "execute".to_string(),
693 timestamp: "2026-01-01T00:00:00Z".to_string(),
694 hmac: None,
695 verified: None,
696 };
697 assert_ne!(
698 call_chain_entry_signing_content(&entry1),
699 call_chain_entry_signing_content(&entry2)
700 );
701 }
702
703 #[test]
708 fn test_compute_and_verify_hmac_roundtrip() {
709 let key = test_key(0xAB);
710 let data = b"hello world";
711 let hmac_hex = compute_call_chain_hmac(&key, data).unwrap();
712 assert!(verify_call_chain_hmac(&key, data, &hmac_hex).unwrap());
713 }
714
715 #[test]
716 fn test_verify_hmac_wrong_key_fails() {
717 let key1 = test_key(0xAB);
718 let key2 = test_key(0xCD);
719 let data = b"hello world";
720 let hmac_hex = compute_call_chain_hmac(&key1, data).unwrap();
721 assert!(!verify_call_chain_hmac(&key2, data, &hmac_hex).unwrap());
722 }
723
724 #[test]
725 fn test_verify_hmac_wrong_data_fails() {
726 let key = test_key(0xAB);
727 let hmac_hex = compute_call_chain_hmac(&key, b"hello").unwrap();
728 assert!(!verify_call_chain_hmac(&key, b"world", &hmac_hex).unwrap());
729 }
730
731 #[test]
732 fn test_verify_hmac_invalid_hex_returns_false() {
733 let key = test_key(0xAB);
734 let result = verify_call_chain_hmac(&key, b"data", "not-valid-hex!!");
735 assert_eq!(result, Ok(false));
736 }
737
738 #[test]
739 fn test_compute_hmac_produces_hex_string() {
740 let key = test_key(0x42);
741 let hmac_hex = compute_call_chain_hmac(&key, b"test data").unwrap();
742 assert!(hmac_hex.chars().all(|c| c.is_ascii_hexdigit()));
743 assert_eq!(hmac_hex.len(), 64); }
745
746 #[test]
751 fn test_build_current_agent_entry_without_hmac() {
752 let entry = build_current_agent_entry(Some("my-agent"), "read_file", "execute", None);
753 assert_eq!(entry.agent_id, "my-agent");
754 assert_eq!(entry.tool, "read_file");
755 assert_eq!(entry.function, "execute");
756 assert!(entry.hmac.is_none());
757 assert!(entry.verified.is_none());
758 assert!(chrono::DateTime::parse_from_rfc3339(&entry.timestamp).is_ok());
760 }
761
762 #[test]
763 fn test_build_current_agent_entry_with_hmac() {
764 let key = test_key(0xAB);
765 let entry = build_current_agent_entry(Some("my-agent"), "read_file", "execute", Some(&key));
766 assert_eq!(entry.agent_id, "my-agent");
767 assert!(entry.hmac.is_some());
768 assert_eq!(entry.verified, Some(true));
769 let hmac_hex = entry.hmac.as_ref().unwrap();
771 assert!(hmac_hex.chars().all(|c| c.is_ascii_hexdigit()));
772 }
773
774 #[test]
775 fn test_build_current_agent_entry_no_agent_id_defaults_to_unknown() {
776 let entry = build_current_agent_entry(None, "tool", "func", None);
777 assert_eq!(entry.agent_id, "unknown");
778 }
779
780 #[test]
781 fn test_build_current_agent_entry_hmac_verifies() {
782 let key = test_key(0x42);
783 let entry = build_current_agent_entry(Some("test-agent"), "my_tool", "my_func", Some(&key));
784 let content = call_chain_entry_signing_content(&entry);
785 let hmac_hex = entry.hmac.as_ref().unwrap();
786 assert!(verify_call_chain_hmac(&key, &content, hmac_hex).unwrap());
787 }
788
789 #[test]
794 fn test_build_audit_context_basic() {
795 let ctx = build_audit_context("session-123", json!({}), &Option::<OAuthClaims>::None);
796 assert_eq!(ctx["source"], "http_proxy");
797 assert_eq!(ctx["session"], "session-123");
798 }
799
800 #[test]
801 fn test_build_audit_context_with_extra_fields() {
802 let extra = json!({"foo": "bar", "count": 42});
803 let ctx = build_audit_context("sess-1", extra, &Option::<OAuthClaims>::None);
804 assert_eq!(ctx["foo"], "bar");
805 assert_eq!(ctx["count"], 42);
806 }
807
808 #[test]
809 fn test_build_audit_context_with_oauth_claims() {
810 let claims = OAuthClaims {
811 sub: "user@example.com".to_string(),
812 iss: "https://issuer.example.com".to_string(),
813 aud: vec!["api".to_string()],
814 exp: 0,
815 iat: 0,
816 scope: "read write".to_string(),
817 resource: None,
818 cnf: None,
819 };
820 let ctx = build_audit_context("sess-1", json!({}), &Some(claims));
821 assert_eq!(ctx["oauth_subject"], "user@example.com");
822 assert_eq!(ctx["oauth_scopes"], "read write");
823 }
824
825 #[test]
826 fn test_build_audit_context_oauth_empty_scope_omitted() {
827 let claims = OAuthClaims {
828 sub: "user@example.com".to_string(),
829 iss: String::new(),
830 aud: Vec::new(),
831 exp: 0,
832 iat: 0,
833 scope: String::new(),
834 resource: None,
835 cnf: None,
836 };
837 let ctx = build_audit_context("sess-1", json!({}), &Some(claims));
838 assert_eq!(ctx["oauth_subject"], "user@example.com");
839 assert!(ctx.get("oauth_scopes").is_none());
840 }
841
842 #[test]
847 fn test_build_audit_context_with_chain_empty_chain_no_field() {
848 let ctx =
849 build_audit_context_with_chain("sess-1", json!({}), &Option::<OAuthClaims>::None, &[]);
850 assert!(ctx.get("call_chain").is_none());
851 }
852
853 #[test]
854 fn test_build_audit_context_with_chain_includes_chain() {
855 let chain = vec![vellaveto_types::CallChainEntry {
856 agent_id: "upstream-agent".to_string(),
857 tool: "read_file".to_string(),
858 function: "execute".to_string(),
859 timestamp: "2026-01-01T00:00:00Z".to_string(),
860 hmac: None,
861 verified: None,
862 }];
863 let ctx = build_audit_context_with_chain(
864 "sess-1",
865 json!({}),
866 &Option::<OAuthClaims>::None,
867 &chain,
868 );
869 let chain_val = ctx.get("call_chain").unwrap();
870 assert!(chain_val.is_array());
871 assert_eq!(chain_val.as_array().unwrap().len(), 1);
872 assert_eq!(chain_val[0]["agent_id"], "upstream-agent");
873 }
874
875 #[test]
880 fn test_validate_call_chain_header_absent_is_ok() {
881 let headers = HeaderMap::new();
882 let limits = vellaveto_config::LimitsConfig::default();
883 assert!(validate_call_chain_header(&headers, &limits).is_ok());
884 }
885
886 #[test]
887 fn test_validate_call_chain_header_valid_json_array() {
888 let mut headers = HeaderMap::new();
889 let chain = json!([{
890 "agent_id": "agent-1",
891 "tool": "read_file",
892 "function": "execute",
893 "timestamp": "2026-01-01T00:00:00Z"
894 }]);
895 headers.insert(super::X_UPSTREAM_AGENTS, chain.to_string().parse().unwrap());
896 let limits = vellaveto_config::LimitsConfig::default();
897 assert!(validate_call_chain_header(&headers, &limits).is_ok());
898 }
899
900 #[test]
901 fn test_validate_call_chain_header_invalid_json_rejected() {
902 let mut headers = HeaderMap::new();
903 headers.insert(super::X_UPSTREAM_AGENTS, "not valid json".parse().unwrap());
904 let limits = vellaveto_config::LimitsConfig::default();
905 let err = validate_call_chain_header(&headers, &limits).unwrap_err();
906 assert!(err.contains("not valid JSON"));
907 }
908
909 #[test]
910 fn test_validate_call_chain_header_oversized_rejected() {
911 let mut limits = vellaveto_config::LimitsConfig::default();
912 limits.max_call_chain_header_bytes = 10; let mut headers = HeaderMap::new();
914 let chain = json!([{
915 "agent_id": "agent-1",
916 "tool": "read_file",
917 "function": "execute",
918 "timestamp": "2026-01-01T00:00:00Z"
919 }]);
920 headers.insert(super::X_UPSTREAM_AGENTS, chain.to_string().parse().unwrap());
921 let err = validate_call_chain_header(&headers, &limits).unwrap_err();
922 assert!(err.contains("size limit"));
923 }
924
925 #[test]
926 fn test_validate_call_chain_header_too_many_entries_rejected() {
927 let mut limits = vellaveto_config::LimitsConfig::default();
928 limits.max_call_chain_length = 1;
929 let chain = json!([
930 {"agent_id": "a1", "tool": "t1", "function": "f1", "timestamp": "2026-01-01T00:00:00Z"},
931 {"agent_id": "a2", "tool": "t2", "function": "f2", "timestamp": "2026-01-01T00:00:01Z"}
932 ]);
933 let mut headers = HeaderMap::new();
934 headers.insert(super::X_UPSTREAM_AGENTS, chain.to_string().parse().unwrap());
935 let err = validate_call_chain_header(&headers, &limits).unwrap_err();
936 assert!(err.contains("entry limit"));
937 }
938
939 #[test]
944 fn test_check_privilege_escalation_empty_chain_no_escalation() {
945 let engine = PolicyEngine::with_policies(false, &[]).unwrap();
946 let action = Action::new("tool", "func", json!({}));
947 let result = check_privilege_escalation(&engine, &[], &action, &[], None);
948 assert!(!result.escalation_detected);
949 assert!(result.escalating_from_agent.is_none());
950 assert!(result.upstream_deny_reason.is_none());
951 }
952}