sentinel_agent_protocol/
protocol.rs

1//! Agent protocol types and constants.
2//!
3//! This module defines the wire protocol types for communication between
4//! the proxy dataplane and external processing agents.
5
6use bytes::Bytes;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Agent protocol version
11pub const PROTOCOL_VERSION: u32 = 1;
12
13/// Maximum message size (10MB)
14pub const MAX_MESSAGE_SIZE: usize = 10 * 1024 * 1024;
15
16/// Agent event type
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum EventType {
20    /// Agent configuration (sent once when agent connects)
21    Configure,
22    /// Request headers received
23    RequestHeaders,
24    /// Request body chunk received
25    RequestBodyChunk,
26    /// Response headers received
27    ResponseHeaders,
28    /// Response body chunk received
29    ResponseBodyChunk,
30    /// Request/response complete (for logging)
31    RequestComplete,
32    /// WebSocket frame received (after upgrade)
33    WebSocketFrame,
34    /// Guardrail content inspection (prompt injection, PII detection)
35    GuardrailInspect,
36}
37
38/// Agent decision
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
40#[serde(rename_all = "snake_case")]
41pub enum Decision {
42    /// Allow the request/response to continue
43    #[default]
44    Allow,
45    /// Block the request/response
46    Block {
47        /// HTTP status code to return
48        status: u16,
49        /// Optional response body
50        body: Option<String>,
51        /// Optional response headers
52        headers: Option<HashMap<String, String>>,
53    },
54    /// Redirect the request
55    Redirect {
56        /// Redirect URL
57        url: String,
58        /// HTTP status code (301, 302, 303, 307, 308)
59        status: u16,
60    },
61    /// Challenge the client (e.g., CAPTCHA)
62    Challenge {
63        /// Challenge type
64        challenge_type: String,
65        /// Challenge parameters
66        params: HashMap<String, String>,
67    },
68}
69
70/// Header modification operation
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "snake_case")]
73pub enum HeaderOp {
74    /// Set a header (replace if exists)
75    Set { name: String, value: String },
76    /// Add a header (append if exists)
77    Add { name: String, value: String },
78    /// Remove a header
79    Remove { name: String },
80}
81
82// ============================================================================
83// Body Mutation
84// ============================================================================
85
86/// Body mutation from agent
87///
88/// Allows agents to modify body content during streaming:
89/// - `None` data: pass through original chunk unchanged
90/// - `Some(empty)`: drop the chunk entirely
91/// - `Some(data)`: replace chunk with modified content
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct BodyMutation {
94    /// Modified body data (base64 encoded for JSON transport)
95    ///
96    /// - `None`: use original chunk unchanged
97    /// - `Some("")`: drop this chunk
98    /// - `Some(data)`: replace chunk with this data
99    pub data: Option<String>,
100
101    /// Chunk index this mutation applies to
102    ///
103    /// Must match the `chunk_index` from the body chunk event.
104    #[serde(default)]
105    pub chunk_index: u32,
106}
107
108impl BodyMutation {
109    /// Create a pass-through mutation (no change)
110    pub fn pass_through(chunk_index: u32) -> Self {
111        Self {
112            data: None,
113            chunk_index,
114        }
115    }
116
117    /// Create a mutation that drops the chunk
118    pub fn drop_chunk(chunk_index: u32) -> Self {
119        Self {
120            data: Some(String::new()),
121            chunk_index,
122        }
123    }
124
125    /// Create a mutation that replaces the chunk
126    pub fn replace(chunk_index: u32, data: String) -> Self {
127        Self {
128            data: Some(data),
129            chunk_index,
130        }
131    }
132
133    /// Check if this mutation passes through unchanged
134    pub fn is_pass_through(&self) -> bool {
135        self.data.is_none()
136    }
137
138    /// Check if this mutation drops the chunk
139    pub fn is_drop(&self) -> bool {
140        matches!(&self.data, Some(d) if d.is_empty())
141    }
142}
143
144/// Request metadata sent to agents
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct RequestMetadata {
147    /// Correlation ID for request tracing
148    pub correlation_id: String,
149    /// Request ID (internal)
150    pub request_id: String,
151    /// Client IP address
152    pub client_ip: String,
153    /// Client port
154    pub client_port: u16,
155    /// Server name (SNI or Host header)
156    pub server_name: Option<String>,
157    /// Protocol (HTTP/1.1, HTTP/2, etc.)
158    pub protocol: String,
159    /// TLS version if applicable
160    pub tls_version: Option<String>,
161    /// TLS cipher suite if applicable
162    pub tls_cipher: Option<String>,
163    /// Route ID that matched
164    pub route_id: Option<String>,
165    /// Upstream ID
166    pub upstream_id: Option<String>,
167    /// Request start timestamp (RFC3339)
168    pub timestamp: String,
169    /// W3C Trace Context traceparent header (for distributed tracing)
170    ///
171    /// Format: `{version}-{trace-id}-{parent-id}-{trace-flags}`
172    /// Example: `00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01`
173    ///
174    /// Agents can use this to create child spans that link to the proxy's span.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub traceparent: Option<String>,
177}
178
179/// Configure event
180///
181/// Sent once when an agent connects, before any request events.
182/// Contains agent-specific configuration from the proxy's config file.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ConfigureEvent {
185    /// Agent ID (from proxy config)
186    pub agent_id: String,
187    /// Agent-specific configuration (JSON object)
188    ///
189    /// The structure of this config depends on the agent type.
190    /// Agents should parse this into their own config struct.
191    pub config: serde_json::Value,
192}
193
194/// Request headers event
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct RequestHeadersEvent {
197    /// Event metadata
198    pub metadata: RequestMetadata,
199    /// HTTP method
200    pub method: String,
201    /// Request URI
202    pub uri: String,
203    /// HTTP headers
204    pub headers: HashMap<String, Vec<String>>,
205}
206
207/// Request body chunk event
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct RequestBodyChunkEvent {
210    /// Correlation ID
211    pub correlation_id: String,
212    /// Body chunk data (base64 encoded for JSON transport)
213    pub data: String,
214    /// Is this the last chunk?
215    pub is_last: bool,
216    /// Total body size if known
217    pub total_size: Option<usize>,
218    /// Chunk index for ordering (0-based)
219    ///
220    /// Used to match mutations to chunks and ensure ordering.
221    #[serde(default)]
222    pub chunk_index: u32,
223    /// Bytes received so far (cumulative)
224    #[serde(default)]
225    pub bytes_received: usize,
226}
227
228/// Response headers event
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ResponseHeadersEvent {
231    /// Correlation ID
232    pub correlation_id: String,
233    /// HTTP status code
234    pub status: u16,
235    /// HTTP headers
236    pub headers: HashMap<String, Vec<String>>,
237}
238
239/// Response body chunk event
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct ResponseBodyChunkEvent {
242    /// Correlation ID
243    pub correlation_id: String,
244    /// Body chunk data (base64 encoded for JSON transport)
245    pub data: String,
246    /// Is this the last chunk?
247    pub is_last: bool,
248    /// Total body size if known
249    pub total_size: Option<usize>,
250    /// Chunk index for ordering (0-based)
251    #[serde(default)]
252    pub chunk_index: u32,
253    /// Bytes sent so far (cumulative)
254    #[serde(default)]
255    pub bytes_sent: usize,
256}
257
258// ============================================================================
259// Binary Body Chunk Events (Zero-Copy)
260// ============================================================================
261
262/// Binary request body chunk event.
263///
264/// This type uses `Bytes` for zero-copy body streaming, avoiding the base64
265/// encode/decode overhead of `RequestBodyChunkEvent`. Use this type for:
266/// - Binary UDS transport (with `binary-uds` feature)
267/// - gRPC transport (protobuf already uses bytes)
268/// - Any transport that supports raw binary data
269///
270/// For JSON transport, use `RequestBodyChunkEvent` with base64-encoded data.
271#[derive(Debug, Clone)]
272pub struct BinaryRequestBodyChunkEvent {
273    /// Correlation ID
274    pub correlation_id: String,
275    /// Body chunk data (raw bytes, no encoding)
276    pub data: Bytes,
277    /// Is this the last chunk?
278    pub is_last: bool,
279    /// Total body size if known
280    pub total_size: Option<usize>,
281    /// Chunk index for ordering (0-based)
282    pub chunk_index: u32,
283    /// Bytes received so far (cumulative)
284    pub bytes_received: usize,
285}
286
287/// Binary response body chunk event.
288///
289/// This type uses `Bytes` for zero-copy body streaming, avoiding the base64
290/// encode/decode overhead of `ResponseBodyChunkEvent`. Use this type for:
291/// - Binary UDS transport (with `binary-uds` feature)
292/// - gRPC transport (protobuf already uses bytes)
293/// - Any transport that supports raw binary data
294///
295/// For JSON transport, use `ResponseBodyChunkEvent` with base64-encoded data.
296#[derive(Debug, Clone)]
297pub struct BinaryResponseBodyChunkEvent {
298    /// Correlation ID
299    pub correlation_id: String,
300    /// Body chunk data (raw bytes, no encoding)
301    pub data: Bytes,
302    /// Is this the last chunk?
303    pub is_last: bool,
304    /// Total body size if known
305    pub total_size: Option<usize>,
306    /// Chunk index for ordering (0-based)
307    pub chunk_index: u32,
308    /// Bytes sent so far (cumulative)
309    pub bytes_sent: usize,
310}
311
312impl BinaryRequestBodyChunkEvent {
313    /// Create a new binary request body chunk event.
314    pub fn new(
315        correlation_id: impl Into<String>,
316        data: impl Into<Bytes>,
317        chunk_index: u32,
318        is_last: bool,
319    ) -> Self {
320        let data = data.into();
321        Self {
322            correlation_id: correlation_id.into(),
323            bytes_received: data.len(),
324            data,
325            is_last,
326            total_size: None,
327            chunk_index,
328        }
329    }
330
331    /// Set the total body size.
332    pub fn with_total_size(mut self, size: usize) -> Self {
333        self.total_size = Some(size);
334        self
335    }
336
337    /// Set cumulative bytes received.
338    pub fn with_bytes_received(mut self, bytes: usize) -> Self {
339        self.bytes_received = bytes;
340        self
341    }
342}
343
344impl BinaryResponseBodyChunkEvent {
345    /// Create a new binary response body chunk event.
346    pub fn new(
347        correlation_id: impl Into<String>,
348        data: impl Into<Bytes>,
349        chunk_index: u32,
350        is_last: bool,
351    ) -> Self {
352        let data = data.into();
353        Self {
354            correlation_id: correlation_id.into(),
355            bytes_sent: data.len(),
356            data,
357            is_last,
358            total_size: None,
359            chunk_index,
360        }
361    }
362
363    /// Set the total body size.
364    pub fn with_total_size(mut self, size: usize) -> Self {
365        self.total_size = Some(size);
366        self
367    }
368
369    /// Set cumulative bytes sent.
370    pub fn with_bytes_sent(mut self, bytes: usize) -> Self {
371        self.bytes_sent = bytes;
372        self
373    }
374}
375
376// ============================================================================
377// Conversions between String (base64) and Binary body chunk types
378// ============================================================================
379
380impl From<BinaryRequestBodyChunkEvent> for RequestBodyChunkEvent {
381    /// Convert binary body chunk to base64-encoded JSON-compatible type.
382    fn from(event: BinaryRequestBodyChunkEvent) -> Self {
383        use base64::{engine::general_purpose::STANDARD, Engine as _};
384        Self {
385            correlation_id: event.correlation_id,
386            data: STANDARD.encode(&event.data),
387            is_last: event.is_last,
388            total_size: event.total_size,
389            chunk_index: event.chunk_index,
390            bytes_received: event.bytes_received,
391        }
392    }
393}
394
395impl From<&RequestBodyChunkEvent> for BinaryRequestBodyChunkEvent {
396    /// Convert base64-encoded body chunk to binary type.
397    ///
398    /// If base64 decoding fails, falls back to treating data as raw UTF-8 bytes.
399    fn from(event: &RequestBodyChunkEvent) -> Self {
400        use base64::{engine::general_purpose::STANDARD, Engine as _};
401        let data = STANDARD
402            .decode(&event.data)
403            .map(Bytes::from)
404            .unwrap_or_else(|_| Bytes::copy_from_slice(event.data.as_bytes()));
405        Self {
406            correlation_id: event.correlation_id.clone(),
407            data,
408            is_last: event.is_last,
409            total_size: event.total_size,
410            chunk_index: event.chunk_index,
411            bytes_received: event.bytes_received,
412        }
413    }
414}
415
416impl From<BinaryResponseBodyChunkEvent> for ResponseBodyChunkEvent {
417    /// Convert binary body chunk to base64-encoded JSON-compatible type.
418    fn from(event: BinaryResponseBodyChunkEvent) -> Self {
419        use base64::{engine::general_purpose::STANDARD, Engine as _};
420        Self {
421            correlation_id: event.correlation_id,
422            data: STANDARD.encode(&event.data),
423            is_last: event.is_last,
424            total_size: event.total_size,
425            chunk_index: event.chunk_index,
426            bytes_sent: event.bytes_sent,
427        }
428    }
429}
430
431impl From<&ResponseBodyChunkEvent> for BinaryResponseBodyChunkEvent {
432    /// Convert base64-encoded body chunk to binary type.
433    ///
434    /// If base64 decoding fails, falls back to treating data as raw UTF-8 bytes.
435    fn from(event: &ResponseBodyChunkEvent) -> Self {
436        use base64::{engine::general_purpose::STANDARD, Engine as _};
437        let data = STANDARD
438            .decode(&event.data)
439            .map(Bytes::from)
440            .unwrap_or_else(|_| Bytes::copy_from_slice(event.data.as_bytes()));
441        Self {
442            correlation_id: event.correlation_id.clone(),
443            data,
444            is_last: event.is_last,
445            total_size: event.total_size,
446            chunk_index: event.chunk_index,
447            bytes_sent: event.bytes_sent,
448        }
449    }
450}
451
452/// Request complete event (for logging/audit)
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct RequestCompleteEvent {
455    /// Correlation ID
456    pub correlation_id: String,
457    /// Final HTTP status code
458    pub status: u16,
459    /// Request duration in milliseconds
460    pub duration_ms: u64,
461    /// Request body size
462    pub request_body_size: usize,
463    /// Response body size
464    pub response_body_size: usize,
465    /// Upstream attempts
466    pub upstream_attempts: u32,
467    /// Error if any
468    pub error: Option<String>,
469}
470
471// ============================================================================
472// WebSocket Frame Events
473// ============================================================================
474
475/// WebSocket frame event
476///
477/// Sent to agents after a WebSocket upgrade when frame inspection is enabled.
478/// Each frame is sent individually for inspection.
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct WebSocketFrameEvent {
481    /// Correlation ID (same as the original HTTP upgrade request)
482    pub correlation_id: String,
483    /// Frame opcode: "text", "binary", "ping", "pong", "close", "continuation"
484    pub opcode: String,
485    /// Frame payload (base64 encoded for JSON transport)
486    pub data: String,
487    /// Direction: true = client->server, false = server->client
488    pub client_to_server: bool,
489    /// Frame index for this connection (0-based, per direction)
490    pub frame_index: u64,
491    /// FIN bit - true if final frame of message (for fragmented messages)
492    pub fin: bool,
493    /// Route ID
494    pub route_id: Option<String>,
495    /// Client IP
496    pub client_ip: String,
497}
498
499/// WebSocket opcode
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
501#[serde(rename_all = "snake_case")]
502pub enum WebSocketOpcode {
503    /// Continuation frame (0x0)
504    Continuation,
505    /// Text frame (0x1)
506    Text,
507    /// Binary frame (0x2)
508    Binary,
509    /// Connection close (0x8)
510    Close,
511    /// Ping (0x9)
512    Ping,
513    /// Pong (0xA)
514    Pong,
515}
516
517impl WebSocketOpcode {
518    /// Convert opcode to string representation
519    pub fn as_str(&self) -> &'static str {
520        match self {
521            Self::Continuation => "continuation",
522            Self::Text => "text",
523            Self::Binary => "binary",
524            Self::Close => "close",
525            Self::Ping => "ping",
526            Self::Pong => "pong",
527        }
528    }
529
530    /// Parse from byte value
531    pub fn from_u8(value: u8) -> Option<Self> {
532        match value {
533            0x0 => Some(Self::Continuation),
534            0x1 => Some(Self::Text),
535            0x2 => Some(Self::Binary),
536            0x8 => Some(Self::Close),
537            0x9 => Some(Self::Ping),
538            0xA => Some(Self::Pong),
539            _ => None,
540        }
541    }
542
543    /// Convert to byte value
544    pub fn as_u8(&self) -> u8 {
545        match self {
546            Self::Continuation => 0x0,
547            Self::Text => 0x1,
548            Self::Binary => 0x2,
549            Self::Close => 0x8,
550            Self::Ping => 0x9,
551            Self::Pong => 0xA,
552        }
553    }
554}
555
556/// WebSocket frame decision
557///
558/// Agents return this decision for WebSocket frame events.
559#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
560#[serde(rename_all = "snake_case")]
561pub enum WebSocketDecision {
562    /// Allow frame to pass through
563    #[default]
564    Allow,
565    /// Drop this frame silently (don't forward)
566    Drop,
567    /// Close the WebSocket connection
568    Close {
569        /// Close code (RFC 6455 section 7.4.1)
570        code: u16,
571        /// Close reason
572        reason: String,
573    },
574}
575
576/// Agent request message
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct AgentRequest {
579    /// Protocol version
580    pub version: u32,
581    /// Event type
582    pub event_type: EventType,
583    /// Event payload (JSON)
584    pub payload: serde_json::Value,
585}
586
587/// Agent response message
588#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct AgentResponse {
590    /// Protocol version
591    pub version: u32,
592    /// Decision
593    pub decision: Decision,
594    /// Header modifications for request
595    #[serde(default)]
596    pub request_headers: Vec<HeaderOp>,
597    /// Header modifications for response
598    #[serde(default)]
599    pub response_headers: Vec<HeaderOp>,
600    /// Routing metadata modifications
601    #[serde(default)]
602    pub routing_metadata: HashMap<String, String>,
603    /// Audit metadata
604    #[serde(default)]
605    pub audit: AuditMetadata,
606
607    // ========================================================================
608    // Streaming-specific fields
609    // ========================================================================
610    /// Agent needs more data to make a final decision
611    ///
612    /// When `true`, the current `decision` is provisional and may change
613    /// after processing more body chunks. The proxy should continue
614    /// streaming body data to this agent.
615    ///
616    /// When `false` (default), the decision is final.
617    #[serde(default)]
618    pub needs_more: bool,
619
620    /// Request body mutation (for streaming mode)
621    ///
622    /// If present, applies the mutation to the current request body chunk.
623    /// Only valid for `RequestBodyChunk` events.
624    #[serde(default)]
625    pub request_body_mutation: Option<BodyMutation>,
626
627    /// Response body mutation (for streaming mode)
628    ///
629    /// If present, applies the mutation to the current response body chunk.
630    /// Only valid for `ResponseBodyChunk` events.
631    #[serde(default)]
632    pub response_body_mutation: Option<BodyMutation>,
633
634    /// WebSocket frame decision
635    ///
636    /// Only valid for `WebSocketFrame` events. If not set, defaults to Allow.
637    #[serde(default)]
638    pub websocket_decision: Option<WebSocketDecision>,
639}
640
641impl AgentResponse {
642    /// Create a default allow response
643    pub fn default_allow() -> Self {
644        Self {
645            version: PROTOCOL_VERSION,
646            decision: Decision::Allow,
647            request_headers: vec![],
648            response_headers: vec![],
649            routing_metadata: HashMap::new(),
650            audit: AuditMetadata::default(),
651            needs_more: false,
652            request_body_mutation: None,
653            response_body_mutation: None,
654            websocket_decision: None,
655        }
656    }
657
658    /// Create a block response
659    pub fn block(status: u16, body: Option<String>) -> Self {
660        Self {
661            version: PROTOCOL_VERSION,
662            decision: Decision::Block {
663                status,
664                body,
665                headers: None,
666            },
667            request_headers: vec![],
668            response_headers: vec![],
669            routing_metadata: HashMap::new(),
670            audit: AuditMetadata::default(),
671            needs_more: false,
672            request_body_mutation: None,
673            response_body_mutation: None,
674            websocket_decision: None,
675        }
676    }
677
678    /// Create a redirect response
679    pub fn redirect(url: String, status: u16) -> Self {
680        Self {
681            version: PROTOCOL_VERSION,
682            decision: Decision::Redirect { url, status },
683            request_headers: vec![],
684            response_headers: vec![],
685            routing_metadata: HashMap::new(),
686            audit: AuditMetadata::default(),
687            needs_more: false,
688            request_body_mutation: None,
689            response_body_mutation: None,
690            websocket_decision: None,
691        }
692    }
693
694    /// Create a streaming response indicating more data is needed
695    pub fn needs_more_data() -> Self {
696        Self {
697            version: PROTOCOL_VERSION,
698            decision: Decision::Allow,
699            request_headers: vec![],
700            response_headers: vec![],
701            routing_metadata: HashMap::new(),
702            audit: AuditMetadata::default(),
703            needs_more: true,
704            request_body_mutation: None,
705            response_body_mutation: None,
706            websocket_decision: None,
707        }
708    }
709
710    /// Create a WebSocket allow response
711    pub fn websocket_allow() -> Self {
712        Self {
713            websocket_decision: Some(WebSocketDecision::Allow),
714            ..Self::default_allow()
715        }
716    }
717
718    /// Create a WebSocket drop response (drop the frame, don't forward)
719    pub fn websocket_drop() -> Self {
720        Self {
721            websocket_decision: Some(WebSocketDecision::Drop),
722            ..Self::default_allow()
723        }
724    }
725
726    /// Create a WebSocket close response (close the connection)
727    pub fn websocket_close(code: u16, reason: String) -> Self {
728        Self {
729            websocket_decision: Some(WebSocketDecision::Close { code, reason }),
730            ..Self::default_allow()
731        }
732    }
733
734    /// Set WebSocket decision
735    pub fn with_websocket_decision(mut self, decision: WebSocketDecision) -> Self {
736        self.websocket_decision = Some(decision);
737        self
738    }
739
740    /// Create a streaming response with body mutation
741    pub fn with_request_body_mutation(mut self, mutation: BodyMutation) -> Self {
742        self.request_body_mutation = Some(mutation);
743        self
744    }
745
746    /// Create a streaming response with response body mutation
747    pub fn with_response_body_mutation(mut self, mutation: BodyMutation) -> Self {
748        self.response_body_mutation = Some(mutation);
749        self
750    }
751
752    /// Set needs_more flag
753    pub fn set_needs_more(mut self, needs_more: bool) -> Self {
754        self.needs_more = needs_more;
755        self
756    }
757
758    /// Add a request header modification
759    pub fn add_request_header(mut self, op: HeaderOp) -> Self {
760        self.request_headers.push(op);
761        self
762    }
763
764    /// Add a response header modification
765    pub fn add_response_header(mut self, op: HeaderOp) -> Self {
766        self.response_headers.push(op);
767        self
768    }
769
770    /// Add audit metadata
771    pub fn with_audit(mut self, audit: AuditMetadata) -> Self {
772        self.audit = audit;
773        self
774    }
775}
776
777/// Audit metadata from agent
778#[derive(Debug, Clone, Default, Serialize, Deserialize)]
779pub struct AuditMetadata {
780    /// Tags for logging/metrics
781    #[serde(default)]
782    pub tags: Vec<String>,
783    /// Rule IDs that matched
784    #[serde(default)]
785    pub rule_ids: Vec<String>,
786    /// Confidence score (0.0 - 1.0)
787    pub confidence: Option<f32>,
788    /// Reason codes
789    #[serde(default)]
790    pub reason_codes: Vec<String>,
791    /// Custom metadata
792    #[serde(default)]
793    pub custom: HashMap<String, serde_json::Value>,
794}
795
796// ============================================================================
797// Guardrail Inspection Types
798// ============================================================================
799
800/// Type of guardrail inspection to perform
801#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
802#[serde(rename_all = "snake_case")]
803pub enum GuardrailInspectionType {
804    /// Prompt injection detection (analyze request content)
805    PromptInjection,
806    /// PII detection (analyze response content)
807    PiiDetection,
808}
809
810/// Guardrail inspection event
811///
812/// Sent to guardrail agents for semantic content analysis.
813/// Used for prompt injection detection on requests and PII detection on responses.
814#[derive(Debug, Clone, Serialize, Deserialize)]
815pub struct GuardrailInspectEvent {
816    /// Correlation ID for request tracing
817    pub correlation_id: String,
818    /// Type of inspection to perform
819    pub inspection_type: GuardrailInspectionType,
820    /// Content to inspect (request body or response content)
821    pub content: String,
822    /// Model name if available (for context)
823    #[serde(skip_serializing_if = "Option::is_none")]
824    pub model: Option<String>,
825    /// PII categories to check (for PII detection)
826    /// e.g., ["ssn", "credit_card", "email", "phone"]
827    #[serde(default)]
828    pub categories: Vec<String>,
829    /// Route ID
830    #[serde(skip_serializing_if = "Option::is_none")]
831    pub route_id: Option<String>,
832    /// Additional metadata for context
833    #[serde(default)]
834    pub metadata: HashMap<String, String>,
835}
836
837/// Guardrail inspection response from agent
838#[derive(Debug, Clone, Serialize, Deserialize)]
839pub struct GuardrailResponse {
840    /// Whether any issues were detected
841    pub detected: bool,
842    /// Confidence score (0.0 - 1.0)
843    #[serde(default)]
844    pub confidence: f64,
845    /// List of detections found
846    #[serde(default)]
847    pub detections: Vec<GuardrailDetection>,
848    /// Redacted content (for PII, if requested)
849    #[serde(skip_serializing_if = "Option::is_none")]
850    pub redacted_content: Option<String>,
851}
852
853impl Default for GuardrailResponse {
854    fn default() -> Self {
855        Self {
856            detected: false,
857            confidence: 0.0,
858            detections: Vec::new(),
859            redacted_content: None,
860        }
861    }
862}
863
864impl GuardrailResponse {
865    /// Create a response indicating nothing detected
866    pub fn clean() -> Self {
867        Self::default()
868    }
869
870    /// Create a response with a detection
871    pub fn with_detection(detection: GuardrailDetection) -> Self {
872        Self {
873            detected: true,
874            confidence: detection.confidence.unwrap_or(1.0),
875            detections: vec![detection],
876            redacted_content: None,
877        }
878    }
879
880    /// Add a detection to the response
881    pub fn add_detection(&mut self, detection: GuardrailDetection) {
882        self.detected = true;
883        if let Some(conf) = detection.confidence {
884            self.confidence = self.confidence.max(conf);
885        }
886        self.detections.push(detection);
887    }
888}
889
890/// A single guardrail detection (prompt injection attempt, PII instance, etc.)
891#[derive(Debug, Clone, Serialize, Deserialize)]
892pub struct GuardrailDetection {
893    /// Category of detection (e.g., "prompt_injection", "ssn", "credit_card")
894    pub category: String,
895    /// Human-readable description of what was detected
896    pub description: String,
897    /// Severity level
898    #[serde(default)]
899    pub severity: DetectionSeverity,
900    /// Confidence score for this detection (0.0 - 1.0)
901    #[serde(skip_serializing_if = "Option::is_none")]
902    pub confidence: Option<f64>,
903    /// Location in content where detection occurred
904    #[serde(skip_serializing_if = "Option::is_none")]
905    pub span: Option<TextSpan>,
906}
907
908impl GuardrailDetection {
909    /// Create a new detection
910    pub fn new(category: impl Into<String>, description: impl Into<String>) -> Self {
911        Self {
912            category: category.into(),
913            description: description.into(),
914            severity: DetectionSeverity::Medium,
915            confidence: None,
916            span: None,
917        }
918    }
919
920    /// Set severity
921    pub fn with_severity(mut self, severity: DetectionSeverity) -> Self {
922        self.severity = severity;
923        self
924    }
925
926    /// Set confidence
927    pub fn with_confidence(mut self, confidence: f64) -> Self {
928        self.confidence = Some(confidence);
929        self
930    }
931
932    /// Set span
933    pub fn with_span(mut self, start: usize, end: usize) -> Self {
934        self.span = Some(TextSpan { start, end });
935        self
936    }
937}
938
939/// Text span indicating location in content
940#[derive(Debug, Clone, Serialize, Deserialize)]
941pub struct TextSpan {
942    /// Start position (byte offset)
943    pub start: usize,
944    /// End position (byte offset, exclusive)
945    pub end: usize,
946}
947
948/// Severity level for guardrail detections
949#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
950#[serde(rename_all = "lowercase")]
951pub enum DetectionSeverity {
952    /// Low severity (informational)
953    Low,
954    /// Medium severity (default)
955    #[default]
956    Medium,
957    /// High severity (should likely block)
958    High,
959    /// Critical severity (must block)
960    Critical,
961}