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