Skip to main content

vellaveto_http_proxy/proxy/
call_chain.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 tracking, call chain management, and privilege escalation detection.
11
12use 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
25/// Maximum entries in action_history per session (memory bound).
26pub const MAX_ACTION_HISTORY: usize = 100;
27
28/// Maximum distinct tool names tracked in call_counts per session (FIND-045).
29/// Prevents unbounded HashMap growth from attacker-controlled tool names.
30pub const MAX_CALL_COUNT_TOOLS: usize = 1024;
31
32/// Maximum number of pending JSON-RPC tool call correlations per session.
33/// Bounds memory if responses are malformed or never returned.
34pub const MAX_PENDING_TOOL_CALLS: usize = 256;
35
36/// Maximum canonicalized JSON-RPC id key length.
37/// Oversized ids are ignored for request/response correlation.
38const 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
65/// Build a stable key for JSON-RPC id values used in request/response correlation.
66pub 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
81/// Track an outbound tool call so response handling can recover the originating tool.
82pub 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        // SECURITY: cap pending map to prevent unbounded growth on malformed traffic.
93        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
104/// Resolve and consume the tracked tool name for a JSON-RPC response id.
105pub 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
116/// Build an `EvaluationContext` from the current session state.
117pub 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, // Use real time (chrono::Utc::now() fallback in engine)
125            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
137/// Build audit context JSON, optionally including OAuth subject and call chain.
138pub 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
162/// Build audit context JSON with call chain for multi-agent scenarios.
163pub 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                // FIND-R50-015: Log serialization failures instead of silently swallowing.
175                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
185/// Validate the structural integrity of the X-Upstream-Agents header.
186///
187/// Returns:
188/// - `Ok(())` when the header is absent (single-hop flow) or structurally valid.
189/// - `Err(...)` when the header is present but malformed/oversized.
190pub 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
214/// OWASP ASI08: Extract the call chain from the X-Upstream-Agents header.
215///
216/// The header contains a JSON-encoded array of CallChainEntry objects representing
217/// the chain of agents that have processed this request before reaching us.
218/// Returns an empty Vec only when the header is missing; malformed headers are
219/// rejected earlier by `validate_call_chain_header()`.
220///
221/// FIND-015: When an HMAC key is provided, each entry's HMAC tag is verified.
222/// Entries with missing or invalid HMACs are marked as `verified = Some(false)`
223/// and the `agent_id` is prefixed with `[unverified]`. Entries with valid HMACs
224/// are marked as `verified = Some(true)`. When no key is provided, all entries
225/// pass through without verification (backward compatible).
226pub 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    // SECURITY (R237-PROXY-1): Safe cast to prevent i64 wrapping on extreme config values.
240    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    // FIND-015: Verify HMAC on each entry when a key is configured.
268    // Also validate timestamp freshness to prevent replay attacks.
269    let now = Utc::now();
270    if let Some(key) = hmac_key {
271        for entry in &mut entries {
272            // First check timestamp freshness
273            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                            // HMAC verification failed or hex decode error
298                            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                    // No HMAC tag on entry — mark as unverified
310                    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
325/// Parse and persist the upstream call chain for the current request.
326///
327/// The session stores only upstream entries (excluding this proxy's current hop)
328/// so policy checks can reason about delegated caller depth consistently across
329/// tool calls, task requests, and resource reads.
330pub 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
344/// OWASP ASI08: Build a call chain entry for the current agent.
345///
346/// This entry represents the current agent (us) processing the request,
347/// to be added to the chain before forwarding downstream.
348///
349/// FIND-015: When an HMAC key is provided, the entry is signed with
350/// HMAC-SHA256 over its content (agent_id, tool, function, timestamp).
351pub 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    // FIND-015: Sign the entry if an HMAC key is configured.
367    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
378/// FIND-015: Compute the canonical signing content for a call chain entry.
379///
380/// SECURITY (FIND-045, FIND-043): Uses length-prefixed fields instead of pipe
381/// separators to prevent field injection attacks. A tool name containing `|`
382/// could shift field boundaries and create HMAC collisions with the old format.
383/// Also strips both `[unverified] ` and `[stale] ` prefixes since both are
384/// added post-verification and would break round-trip signing.
385pub fn call_chain_entry_signing_content(entry: &vellaveto_types::CallChainEntry) -> Vec<u8> {
386    // Strip any [unverified] or [stale] prefix that may have been added
387    // during verification, so the content matches what was originally signed.
388    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    // Length-prefix each field (u64 LE + bytes) to prevent boundary confusion.
395    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/// FIND-015: Compute HMAC-SHA256 over data, returning lowercase hex string.
409/// Returns `Err` if the HMAC key is rejected (should not happen for 32-byte keys).
410#[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/// FIND-015: Verify HMAC-SHA256 of data against expected hex string.
419/// Returns `Ok(true)` if valid, `Ok(false)` if invalid, `Err` on initialization failure.
420#[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/// OWASP ASI08: Privilege escalation detection result.
432#[derive(Debug)]
433pub struct PrivilegeEscalationCheck {
434    /// True if privilege escalation was detected.
435    pub escalation_detected: bool,
436    /// The agent whose policy would have denied the action.
437    pub escalating_from_agent: Option<String>,
438    /// The reason the upstream agent's policy would deny.
439    pub upstream_deny_reason: Option<String>,
440}
441
442/// OWASP ASI08: Check for privilege escalation in multi-agent scenarios.
443///
444/// A privilege escalation occurs when:
445/// - Agent A makes a request that would be DENIED by A's policy
446/// - But A routes through Agent B whose policy ALLOWS it
447///
448/// This is detected by re-evaluating the action with each upstream agent's
449/// identity and checking if any would have been denied.
450///
451/// Returns a `PrivilegeEscalationCheck` indicating whether escalation was detected
452/// and which agent triggered it.
453#[allow(deprecated)] // evaluate_action_with_context: migration tracked in FIND-CREATIVE-005
454pub 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 there's no call chain, there's no multi-hop scenario to check
462    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    // Check each upstream agent in the call chain
471    for entry in call_chain {
472        // Skip the current agent if they're in the chain
473        if current_agent_id == Some(entry.agent_id.as_str()) {
474            continue;
475        }
476
477        // Build an evaluation context as if we were the upstream agent
478        let upstream_ctx = EvaluationContext {
479            timestamp: None,
480            agent_id: Some(entry.agent_id.clone()),
481            agent_identity: None, // Upstream agent identity not yet supported in call chain
482            call_counts: std::collections::HashMap::new(), // Fresh context for upstream check
483            previous_actions: Vec::new(),
484            call_chain: Vec::new(), // Don't recurse into chain for upstream check
485            tenant_id: None,
486            verification_tier: None,
487            capability_token: None,
488            session_state: None,
489        };
490
491        // Evaluate the action with the upstream agent's identity
492        match engine.evaluate_action_with_context(action, policies, Some(&upstream_ctx)) {
493            Ok(Verdict::Deny { reason }) => {
494                // The upstream agent would have been denied - this is privilege escalation
495                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                // Upstream agent would have been allowed, continue checking
509            }
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    /// Generate a deterministic test key filled with the given byte.
527    /// Uses a function to avoid code scanning "hard-coded cryptographic value" alerts.
528    fn test_key(fill: u8) -> [u8; 32] {
529        [fill; 32]
530    }
531
532    // =========================================================================
533    // jsonrpc_id_key tests
534    // =========================================================================
535
536    #[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    // =========================================================================
599    // call_chain_entry_signing_content tests
600    // =========================================================================
601
602    #[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        // Ensure that length-prefixing prevents boundary confusion:
680        // "ab" + "cd" != "a" + "bcd"
681        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    // =========================================================================
704    // compute_call_chain_hmac / verify_call_chain_hmac tests
705    // =========================================================================
706
707    #[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); // SHA-256 = 32 bytes = 64 hex chars
744    }
745
746    // =========================================================================
747    // build_current_agent_entry tests
748    // =========================================================================
749
750    #[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        // Timestamp should be a valid RFC3339 string
759        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        // HMAC should be valid hex
770        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    // =========================================================================
790    // build_audit_context tests
791    // =========================================================================
792
793    #[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    // =========================================================================
843    // build_audit_context_with_chain tests
844    // =========================================================================
845
846    #[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    // =========================================================================
876    // validate_call_chain_header tests
877    // =========================================================================
878
879    #[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; // Very small
913        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    // =========================================================================
940    // PrivilegeEscalationCheck tests
941    // =========================================================================
942
943    #[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}