Skip to main content

grapsus_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 = 2;
12
13/// Maximum message size for gRPC transport (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/// Request headers event
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct RequestHeadersEvent {
182    /// Event metadata
183    pub metadata: RequestMetadata,
184    /// HTTP method
185    pub method: String,
186    /// Request URI
187    pub uri: String,
188    /// HTTP headers
189    pub headers: HashMap<String, Vec<String>>,
190}
191
192/// Request body chunk event
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct RequestBodyChunkEvent {
195    /// Correlation ID
196    pub correlation_id: String,
197    /// Body chunk data (base64 encoded for JSON transport)
198    pub data: String,
199    /// Is this the last chunk?
200    pub is_last: bool,
201    /// Total body size if known
202    pub total_size: Option<usize>,
203    /// Chunk index for ordering (0-based)
204    ///
205    /// Used to match mutations to chunks and ensure ordering.
206    #[serde(default)]
207    pub chunk_index: u32,
208    /// Bytes received so far (cumulative)
209    #[serde(default)]
210    pub bytes_received: usize,
211}
212
213/// Response headers event
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct ResponseHeadersEvent {
216    /// Correlation ID
217    pub correlation_id: String,
218    /// HTTP status code
219    pub status: u16,
220    /// HTTP headers
221    pub headers: HashMap<String, Vec<String>>,
222}
223
224/// Response body chunk event
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ResponseBodyChunkEvent {
227    /// Correlation ID
228    pub correlation_id: String,
229    /// Body chunk data (base64 encoded for JSON transport)
230    pub data: String,
231    /// Is this the last chunk?
232    pub is_last: bool,
233    /// Total body size if known
234    pub total_size: Option<usize>,
235    /// Chunk index for ordering (0-based)
236    #[serde(default)]
237    pub chunk_index: u32,
238    /// Bytes sent so far (cumulative)
239    #[serde(default)]
240    pub bytes_sent: usize,
241}
242
243// ============================================================================
244// Binary Body Chunk Events (Zero-Copy)
245// ============================================================================
246
247/// Binary request body chunk event.
248///
249/// This type uses `Bytes` for zero-copy body streaming, avoiding the base64
250/// encode/decode overhead of `RequestBodyChunkEvent`. Use this type for:
251/// - Binary UDS transport (with `binary-uds` feature)
252/// - gRPC transport (protobuf already uses bytes)
253/// - Any transport that supports raw binary data
254///
255/// For JSON transport, use `RequestBodyChunkEvent` with base64-encoded data.
256#[derive(Debug, Clone)]
257pub struct BinaryRequestBodyChunkEvent {
258    /// Correlation ID
259    pub correlation_id: String,
260    /// Body chunk data (raw bytes, no encoding)
261    pub data: Bytes,
262    /// Is this the last chunk?
263    pub is_last: bool,
264    /// Total body size if known
265    pub total_size: Option<usize>,
266    /// Chunk index for ordering (0-based)
267    pub chunk_index: u32,
268    /// Bytes received so far (cumulative)
269    pub bytes_received: usize,
270}
271
272/// Binary response body chunk event.
273///
274/// This type uses `Bytes` for zero-copy body streaming, avoiding the base64
275/// encode/decode overhead of `ResponseBodyChunkEvent`. Use this type for:
276/// - Binary UDS transport (with `binary-uds` feature)
277/// - gRPC transport (protobuf already uses bytes)
278/// - Any transport that supports raw binary data
279///
280/// For JSON transport, use `ResponseBodyChunkEvent` with base64-encoded data.
281#[derive(Debug, Clone)]
282pub struct BinaryResponseBodyChunkEvent {
283    /// Correlation ID
284    pub correlation_id: String,
285    /// Body chunk data (raw bytes, no encoding)
286    pub data: Bytes,
287    /// Is this the last chunk?
288    pub is_last: bool,
289    /// Total body size if known
290    pub total_size: Option<usize>,
291    /// Chunk index for ordering (0-based)
292    pub chunk_index: u32,
293    /// Bytes sent so far (cumulative)
294    pub bytes_sent: usize,
295}
296
297impl BinaryRequestBodyChunkEvent {
298    /// Create a new binary request body chunk event.
299    pub fn new(
300        correlation_id: impl Into<String>,
301        data: impl Into<Bytes>,
302        chunk_index: u32,
303        is_last: bool,
304    ) -> Self {
305        let data = data.into();
306        Self {
307            correlation_id: correlation_id.into(),
308            bytes_received: data.len(),
309            data,
310            is_last,
311            total_size: None,
312            chunk_index,
313        }
314    }
315
316    /// Set the total body size.
317    pub fn with_total_size(mut self, size: usize) -> Self {
318        self.total_size = Some(size);
319        self
320    }
321
322    /// Set cumulative bytes received.
323    pub fn with_bytes_received(mut self, bytes: usize) -> Self {
324        self.bytes_received = bytes;
325        self
326    }
327}
328
329impl BinaryResponseBodyChunkEvent {
330    /// Create a new binary response body chunk event.
331    pub fn new(
332        correlation_id: impl Into<String>,
333        data: impl Into<Bytes>,
334        chunk_index: u32,
335        is_last: bool,
336    ) -> Self {
337        let data = data.into();
338        Self {
339            correlation_id: correlation_id.into(),
340            bytes_sent: data.len(),
341            data,
342            is_last,
343            total_size: None,
344            chunk_index,
345        }
346    }
347
348    /// Set the total body size.
349    pub fn with_total_size(mut self, size: usize) -> Self {
350        self.total_size = Some(size);
351        self
352    }
353
354    /// Set cumulative bytes sent.
355    pub fn with_bytes_sent(mut self, bytes: usize) -> Self {
356        self.bytes_sent = bytes;
357        self
358    }
359}
360
361// ============================================================================
362// Conversions between String (base64) and Binary body chunk types
363// ============================================================================
364
365impl From<BinaryRequestBodyChunkEvent> for RequestBodyChunkEvent {
366    /// Convert binary body chunk to base64-encoded JSON-compatible type.
367    fn from(event: BinaryRequestBodyChunkEvent) -> Self {
368        use base64::{engine::general_purpose::STANDARD, Engine as _};
369        Self {
370            correlation_id: event.correlation_id,
371            data: STANDARD.encode(&event.data),
372            is_last: event.is_last,
373            total_size: event.total_size,
374            chunk_index: event.chunk_index,
375            bytes_received: event.bytes_received,
376        }
377    }
378}
379
380impl From<&RequestBodyChunkEvent> for BinaryRequestBodyChunkEvent {
381    /// Convert base64-encoded body chunk to binary type.
382    ///
383    /// If base64 decoding fails, falls back to treating data as raw UTF-8 bytes.
384    fn from(event: &RequestBodyChunkEvent) -> Self {
385        use base64::{engine::general_purpose::STANDARD, Engine as _};
386        let data = STANDARD
387            .decode(&event.data)
388            .map(Bytes::from)
389            .unwrap_or_else(|_| Bytes::copy_from_slice(event.data.as_bytes()));
390        Self {
391            correlation_id: event.correlation_id.clone(),
392            data,
393            is_last: event.is_last,
394            total_size: event.total_size,
395            chunk_index: event.chunk_index,
396            bytes_received: event.bytes_received,
397        }
398    }
399}
400
401impl From<BinaryResponseBodyChunkEvent> for ResponseBodyChunkEvent {
402    /// Convert binary body chunk to base64-encoded JSON-compatible type.
403    fn from(event: BinaryResponseBodyChunkEvent) -> Self {
404        use base64::{engine::general_purpose::STANDARD, Engine as _};
405        Self {
406            correlation_id: event.correlation_id,
407            data: STANDARD.encode(&event.data),
408            is_last: event.is_last,
409            total_size: event.total_size,
410            chunk_index: event.chunk_index,
411            bytes_sent: event.bytes_sent,
412        }
413    }
414}
415
416impl From<&ResponseBodyChunkEvent> for BinaryResponseBodyChunkEvent {
417    /// Convert base64-encoded body chunk to binary type.
418    ///
419    /// If base64 decoding fails, falls back to treating data as raw UTF-8 bytes.
420    fn from(event: &ResponseBodyChunkEvent) -> Self {
421        use base64::{engine::general_purpose::STANDARD, Engine as _};
422        let data = STANDARD
423            .decode(&event.data)
424            .map(Bytes::from)
425            .unwrap_or_else(|_| Bytes::copy_from_slice(event.data.as_bytes()));
426        Self {
427            correlation_id: event.correlation_id.clone(),
428            data,
429            is_last: event.is_last,
430            total_size: event.total_size,
431            chunk_index: event.chunk_index,
432            bytes_sent: event.bytes_sent,
433        }
434    }
435}
436
437/// Request complete event (for logging/audit)
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct RequestCompleteEvent {
440    /// Correlation ID
441    pub correlation_id: String,
442    /// Final HTTP status code
443    pub status: u16,
444    /// Request duration in milliseconds
445    pub duration_ms: u64,
446    /// Request body size
447    pub request_body_size: usize,
448    /// Response body size
449    pub response_body_size: usize,
450    /// Upstream attempts
451    pub upstream_attempts: u32,
452    /// Error if any
453    pub error: Option<String>,
454}
455
456// ============================================================================
457// WebSocket Frame Events
458// ============================================================================
459
460/// WebSocket frame event
461///
462/// Sent to agents after a WebSocket upgrade when frame inspection is enabled.
463/// Each frame is sent individually for inspection.
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct WebSocketFrameEvent {
466    /// Correlation ID (same as the original HTTP upgrade request)
467    pub correlation_id: String,
468    /// Frame opcode: "text", "binary", "ping", "pong", "close", "continuation"
469    pub opcode: String,
470    /// Frame payload (base64 encoded for JSON transport)
471    pub data: String,
472    /// Direction: true = client->server, false = server->client
473    pub client_to_server: bool,
474    /// Frame index for this connection (0-based, per direction)
475    pub frame_index: u64,
476    /// FIN bit - true if final frame of message (for fragmented messages)
477    pub fin: bool,
478    /// Route ID
479    pub route_id: Option<String>,
480    /// Client IP
481    pub client_ip: String,
482}
483
484/// WebSocket opcode
485#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
486#[serde(rename_all = "snake_case")]
487pub enum WebSocketOpcode {
488    /// Continuation frame (0x0)
489    Continuation,
490    /// Text frame (0x1)
491    Text,
492    /// Binary frame (0x2)
493    Binary,
494    /// Connection close (0x8)
495    Close,
496    /// Ping (0x9)
497    Ping,
498    /// Pong (0xA)
499    Pong,
500}
501
502impl WebSocketOpcode {
503    /// Convert opcode to string representation
504    pub fn as_str(&self) -> &'static str {
505        match self {
506            Self::Continuation => "continuation",
507            Self::Text => "text",
508            Self::Binary => "binary",
509            Self::Close => "close",
510            Self::Ping => "ping",
511            Self::Pong => "pong",
512        }
513    }
514
515    /// Parse from byte value
516    pub fn from_u8(value: u8) -> Option<Self> {
517        match value {
518            0x0 => Some(Self::Continuation),
519            0x1 => Some(Self::Text),
520            0x2 => Some(Self::Binary),
521            0x8 => Some(Self::Close),
522            0x9 => Some(Self::Ping),
523            0xA => Some(Self::Pong),
524            _ => None,
525        }
526    }
527
528    /// Convert to byte value
529    pub fn as_u8(&self) -> u8 {
530        match self {
531            Self::Continuation => 0x0,
532            Self::Text => 0x1,
533            Self::Binary => 0x2,
534            Self::Close => 0x8,
535            Self::Ping => 0x9,
536            Self::Pong => 0xA,
537        }
538    }
539}
540
541/// WebSocket frame decision
542///
543/// Agents return this decision for WebSocket frame events.
544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
545#[serde(rename_all = "snake_case")]
546pub enum WebSocketDecision {
547    /// Allow frame to pass through
548    #[default]
549    Allow,
550    /// Drop this frame silently (don't forward)
551    Drop,
552    /// Close the WebSocket connection
553    Close {
554        /// Close code (RFC 6455 section 7.4.1)
555        code: u16,
556        /// Close reason
557        reason: String,
558    },
559}
560
561/// Agent response message
562#[derive(Debug, Clone, Serialize, Deserialize)]
563pub struct AgentResponse {
564    /// Protocol version
565    pub version: u32,
566    /// Decision
567    pub decision: Decision,
568    /// Header modifications for request
569    #[serde(default)]
570    pub request_headers: Vec<HeaderOp>,
571    /// Header modifications for response
572    #[serde(default)]
573    pub response_headers: Vec<HeaderOp>,
574    /// Routing metadata modifications
575    #[serde(default)]
576    pub routing_metadata: HashMap<String, String>,
577    /// Audit metadata
578    #[serde(default)]
579    pub audit: AuditMetadata,
580
581    // ========================================================================
582    // Streaming-specific fields
583    // ========================================================================
584    /// Agent needs more data to make a final decision
585    ///
586    /// When `true`, the current `decision` is provisional and may change
587    /// after processing more body chunks. The proxy should continue
588    /// streaming body data to this agent.
589    ///
590    /// When `false` (default), the decision is final.
591    #[serde(default)]
592    pub needs_more: bool,
593
594    /// Request body mutation (for streaming mode)
595    ///
596    /// If present, applies the mutation to the current request body chunk.
597    /// Only valid for `RequestBodyChunk` events.
598    #[serde(default)]
599    pub request_body_mutation: Option<BodyMutation>,
600
601    /// Response body mutation (for streaming mode)
602    ///
603    /// If present, applies the mutation to the current response body chunk.
604    /// Only valid for `ResponseBodyChunk` events.
605    #[serde(default)]
606    pub response_body_mutation: Option<BodyMutation>,
607
608    /// WebSocket frame decision
609    ///
610    /// Only valid for `WebSocketFrame` events. If not set, defaults to Allow.
611    #[serde(default)]
612    pub websocket_decision: Option<WebSocketDecision>,
613}
614
615impl AgentResponse {
616    /// Create a default allow response
617    pub fn default_allow() -> Self {
618        Self {
619            version: PROTOCOL_VERSION,
620            decision: Decision::Allow,
621            request_headers: vec![],
622            response_headers: vec![],
623            routing_metadata: HashMap::new(),
624            audit: AuditMetadata::default(),
625            needs_more: false,
626            request_body_mutation: None,
627            response_body_mutation: None,
628            websocket_decision: None,
629        }
630    }
631
632    /// Create a block response
633    pub fn block(status: u16, body: Option<String>) -> Self {
634        Self {
635            version: PROTOCOL_VERSION,
636            decision: Decision::Block {
637                status,
638                body,
639                headers: None,
640            },
641            request_headers: vec![],
642            response_headers: vec![],
643            routing_metadata: HashMap::new(),
644            audit: AuditMetadata::default(),
645            needs_more: false,
646            request_body_mutation: None,
647            response_body_mutation: None,
648            websocket_decision: None,
649        }
650    }
651
652    /// Create a redirect response
653    pub fn redirect(url: String, status: u16) -> Self {
654        Self {
655            version: PROTOCOL_VERSION,
656            decision: Decision::Redirect { url, status },
657            request_headers: vec![],
658            response_headers: vec![],
659            routing_metadata: HashMap::new(),
660            audit: AuditMetadata::default(),
661            needs_more: false,
662            request_body_mutation: None,
663            response_body_mutation: None,
664            websocket_decision: None,
665        }
666    }
667
668    /// Create a streaming response indicating more data is needed
669    pub fn needs_more_data() -> Self {
670        Self {
671            version: PROTOCOL_VERSION,
672            decision: Decision::Allow,
673            request_headers: vec![],
674            response_headers: vec![],
675            routing_metadata: HashMap::new(),
676            audit: AuditMetadata::default(),
677            needs_more: true,
678            request_body_mutation: None,
679            response_body_mutation: None,
680            websocket_decision: None,
681        }
682    }
683
684    /// Create a WebSocket allow response
685    pub fn websocket_allow() -> Self {
686        Self {
687            websocket_decision: Some(WebSocketDecision::Allow),
688            ..Self::default_allow()
689        }
690    }
691
692    /// Create a WebSocket drop response (drop the frame, don't forward)
693    pub fn websocket_drop() -> Self {
694        Self {
695            websocket_decision: Some(WebSocketDecision::Drop),
696            ..Self::default_allow()
697        }
698    }
699
700    /// Create a WebSocket close response (close the connection)
701    pub fn websocket_close(code: u16, reason: String) -> Self {
702        Self {
703            websocket_decision: Some(WebSocketDecision::Close { code, reason }),
704            ..Self::default_allow()
705        }
706    }
707
708    /// Set WebSocket decision
709    pub fn with_websocket_decision(mut self, decision: WebSocketDecision) -> Self {
710        self.websocket_decision = Some(decision);
711        self
712    }
713
714    /// Create a streaming response with body mutation
715    pub fn with_request_body_mutation(mut self, mutation: BodyMutation) -> Self {
716        self.request_body_mutation = Some(mutation);
717        self
718    }
719
720    /// Create a streaming response with response body mutation
721    pub fn with_response_body_mutation(mut self, mutation: BodyMutation) -> Self {
722        self.response_body_mutation = Some(mutation);
723        self
724    }
725
726    /// Set needs_more flag
727    pub fn set_needs_more(mut self, needs_more: bool) -> Self {
728        self.needs_more = needs_more;
729        self
730    }
731
732    /// Add a request header modification
733    pub fn add_request_header(mut self, op: HeaderOp) -> Self {
734        self.request_headers.push(op);
735        self
736    }
737
738    /// Add a response header modification
739    pub fn add_response_header(mut self, op: HeaderOp) -> Self {
740        self.response_headers.push(op);
741        self
742    }
743
744    /// Add audit metadata
745    pub fn with_audit(mut self, audit: AuditMetadata) -> Self {
746        self.audit = audit;
747        self
748    }
749}
750
751/// Audit metadata from agent
752#[derive(Debug, Clone, Default, Serialize, Deserialize)]
753pub struct AuditMetadata {
754    /// Tags for logging/metrics
755    #[serde(default)]
756    pub tags: Vec<String>,
757    /// Rule IDs that matched
758    #[serde(default)]
759    pub rule_ids: Vec<String>,
760    /// Confidence score (0.0 - 1.0)
761    pub confidence: Option<f32>,
762    /// Reason codes
763    #[serde(default)]
764    pub reason_codes: Vec<String>,
765    /// Custom metadata
766    #[serde(default)]
767    pub custom: HashMap<String, serde_json::Value>,
768}
769
770// ============================================================================
771// Guardrail Inspection Types
772// ============================================================================
773
774/// Type of guardrail inspection to perform
775#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
776#[serde(rename_all = "snake_case")]
777pub enum GuardrailInspectionType {
778    /// Prompt injection detection (analyze request content)
779    PromptInjection,
780    /// PII detection (analyze response content)
781    PiiDetection,
782}
783
784/// Guardrail inspection event
785///
786/// Sent to guardrail agents for semantic content analysis.
787/// Used for prompt injection detection on requests and PII detection on responses.
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct GuardrailInspectEvent {
790    /// Correlation ID for request tracing
791    pub correlation_id: String,
792    /// Type of inspection to perform
793    pub inspection_type: GuardrailInspectionType,
794    /// Content to inspect (request body or response content)
795    pub content: String,
796    /// Model name if available (for context)
797    #[serde(skip_serializing_if = "Option::is_none")]
798    pub model: Option<String>,
799    /// PII categories to check (for PII detection)
800    /// e.g., ["ssn", "credit_card", "email", "phone"]
801    #[serde(default)]
802    pub categories: Vec<String>,
803    /// Route ID
804    #[serde(skip_serializing_if = "Option::is_none")]
805    pub route_id: Option<String>,
806    /// Additional metadata for context
807    #[serde(default)]
808    pub metadata: HashMap<String, String>,
809}
810
811/// Guardrail inspection response from agent
812#[derive(Debug, Clone, Serialize, Deserialize)]
813pub struct GuardrailResponse {
814    /// Whether any issues were detected
815    pub detected: bool,
816    /// Confidence score (0.0 - 1.0)
817    #[serde(default)]
818    pub confidence: f64,
819    /// List of detections found
820    #[serde(default)]
821    pub detections: Vec<GuardrailDetection>,
822    /// Redacted content (for PII, if requested)
823    #[serde(skip_serializing_if = "Option::is_none")]
824    pub redacted_content: Option<String>,
825}
826
827impl Default for GuardrailResponse {
828    fn default() -> Self {
829        Self {
830            detected: false,
831            confidence: 0.0,
832            detections: Vec::new(),
833            redacted_content: None,
834        }
835    }
836}
837
838impl GuardrailResponse {
839    /// Create a response indicating nothing detected
840    pub fn clean() -> Self {
841        Self::default()
842    }
843
844    /// Create a response with a detection
845    pub fn with_detection(detection: GuardrailDetection) -> Self {
846        Self {
847            detected: true,
848            confidence: detection.confidence.unwrap_or(1.0),
849            detections: vec![detection],
850            redacted_content: None,
851        }
852    }
853
854    /// Add a detection to the response
855    pub fn add_detection(&mut self, detection: GuardrailDetection) {
856        self.detected = true;
857        if let Some(conf) = detection.confidence {
858            self.confidence = self.confidence.max(conf);
859        }
860        self.detections.push(detection);
861    }
862}
863
864/// A single guardrail detection (prompt injection attempt, PII instance, etc.)
865#[derive(Debug, Clone, Serialize, Deserialize)]
866pub struct GuardrailDetection {
867    /// Category of detection (e.g., "prompt_injection", "ssn", "credit_card")
868    pub category: String,
869    /// Human-readable description of what was detected
870    pub description: String,
871    /// Severity level
872    #[serde(default)]
873    pub severity: DetectionSeverity,
874    /// Confidence score for this detection (0.0 - 1.0)
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub confidence: Option<f64>,
877    /// Location in content where detection occurred
878    #[serde(skip_serializing_if = "Option::is_none")]
879    pub span: Option<TextSpan>,
880}
881
882impl GuardrailDetection {
883    /// Create a new detection
884    pub fn new(category: impl Into<String>, description: impl Into<String>) -> Self {
885        Self {
886            category: category.into(),
887            description: description.into(),
888            severity: DetectionSeverity::Medium,
889            confidence: None,
890            span: None,
891        }
892    }
893
894    /// Set severity
895    pub fn with_severity(mut self, severity: DetectionSeverity) -> Self {
896        self.severity = severity;
897        self
898    }
899
900    /// Set confidence
901    pub fn with_confidence(mut self, confidence: f64) -> Self {
902        self.confidence = Some(confidence);
903        self
904    }
905
906    /// Set span
907    pub fn with_span(mut self, start: usize, end: usize) -> Self {
908        self.span = Some(TextSpan { start, end });
909        self
910    }
911}
912
913/// Text span indicating location in content
914#[derive(Debug, Clone, Serialize, Deserialize)]
915pub struct TextSpan {
916    /// Start position (byte offset)
917    pub start: usize,
918    /// End position (byte offset, exclusive)
919    pub end: usize,
920}
921
922/// Severity level for guardrail detections
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
924#[serde(rename_all = "lowercase")]
925pub enum DetectionSeverity {
926    /// Low severity (informational)
927    Low,
928    /// Medium severity (default)
929    #[default]
930    Medium,
931    /// High severity (should likely block)
932    High,
933    /// Critical severity (must block)
934    Critical,
935}