Skip to main content

lean_ctx_protocol/
engine_egress.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Egress admission for an operator-owned Engine process (`engine egress-admit`).
3//!
4//! A host that forwards model requests itself (for example an organization's
5//! inference gateway) hands the request to the local Engine, which applies the
6//! same admission the lean-ctx proxy applies before a request leaves: secrets
7//! and personal data are masked, withheld content is never forwarded. The
8//! answer carries the admitted body, the request's most sensitive
9//! classification and the decision receipt. What the host does with the
10//! classification (destination rules) is the host's policy, not the Engine's.
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::common::ValidationError;
16use crate::context_gateway::{ClassificationV1, ContextDecisionReceiptV1};
17
18pub const ENGINE_EGRESS_SCHEMA_VERSION: u32 = 1;
19/// Upper bound for one request document (matches the proxy's body bound).
20pub const MAX_ENGINE_EGRESS_REQUEST_BYTES: usize = 32 * 1024 * 1024;
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct EngineEgressAdmissionRequestV1 {
25    pub schema_version: u32,
26    /// Provider label as the request's wire shape names it (`anthropic`,
27    /// `openai`, ...). Informational: it is recorded in the receipt.
28    pub provider: String,
29    /// Base URL the host will send the request to. Locality (local vs.
30    /// remote) is derived from it; anything not provably local is remote.
31    pub upstream_base: String,
32    /// The model request document exactly as the host would forward it.
33    pub body: Value,
34}
35
36impl EngineEgressAdmissionRequestV1 {
37    pub fn validate(&self) -> Result<(), ValidationError> {
38        if self.schema_version != ENGINE_EGRESS_SCHEMA_VERSION {
39            return Err(ValidationError::new("unsupported egress schema version"));
40        }
41        if self.provider.trim().is_empty() || self.provider.len() > 64 {
42            return Err(ValidationError::new("egress provider label is invalid"));
43        }
44        if self.upstream_base.trim().is_empty() || self.upstream_base.len() > 2048 {
45            return Err(ValidationError::new("egress upstream base is invalid"));
46        }
47        if !self.body.is_object() {
48            return Err(ValidationError::new("egress body must be a JSON object"));
49        }
50        Ok(())
51    }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum EgressDispositionV1 {
57    /// Forward the request unchanged.
58    Forward,
59    /// Forward `body` instead of the original (values were masked).
60    Rewritten,
61    /// Do not forward anything.
62    Refused,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct EngineEgressAdmissionResponseV1 {
68    pub schema_version: u32,
69    pub disposition: EgressDispositionV1,
70    /// The body to forward; absent when the request is refused.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub body: Option<Value>,
73    /// Content-free refusal reason (reason codes only).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub refusal: Option<String>,
76    /// Most sensitive classification found in the request. Absent when the
77    /// gateway inspected nothing (disabled, or an opaque payload): a host must
78    /// then treat the request as unclassified, never as public.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub classification: Option<ClassificationV1>,
81    /// The decision receipt; absent when the gateway is disabled.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub receipt: Option<ContextDecisionReceiptV1>,
84}
85
86impl EngineEgressAdmissionResponseV1 {
87    pub fn validate(&self) -> Result<(), ValidationError> {
88        if self.schema_version != ENGINE_EGRESS_SCHEMA_VERSION {
89            return Err(ValidationError::new("unsupported egress schema version"));
90        }
91        let consistent = match self.disposition {
92            EgressDispositionV1::Refused => self.body.is_none() && self.refusal.is_some(),
93            EgressDispositionV1::Forward | EgressDispositionV1::Rewritten => {
94                self.body.is_some() && self.refusal.is_none()
95            }
96        };
97        if !consistent {
98            return Err(ValidationError::new(
99                "egress body and refusal must match the disposition",
100            ));
101        }
102        if let Some(receipt) = &self.receipt {
103            receipt.validate()?;
104        }
105        Ok(())
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn a_refusal_never_carries_a_body_and_a_forward_always_does() {
115        let mut response = EngineEgressAdmissionResponseV1 {
116            schema_version: ENGINE_EGRESS_SCHEMA_VERSION,
117            disposition: EgressDispositionV1::Refused,
118            body: None,
119            refusal: Some("request withheld by the context gateway (x)".into()),
120            classification: Some(ClassificationV1::Restricted),
121            receipt: None,
122        };
123        assert!(response.validate().is_ok());
124        response.body = Some(serde_json::json!({"model": "m"}));
125        assert!(response.validate().is_err(), "refused with a body");
126        response.disposition = EgressDispositionV1::Forward;
127        response.refusal = None;
128        assert!(response.validate().is_ok());
129        response.body = None;
130        assert!(response.validate().is_err(), "forward without a body");
131    }
132
133    #[test]
134    fn requests_need_an_object_body_and_the_current_schema() {
135        let request = EngineEgressAdmissionRequestV1 {
136            schema_version: ENGINE_EGRESS_SCHEMA_VERSION,
137            provider: "openai".into(),
138            upstream_base: "https://api.openai.com".into(),
139            body: serde_json::json!({"model": "m", "messages": []}),
140        };
141        assert!(request.validate().is_ok());
142        let mut array = request.clone();
143        array.body = serde_json::json!([]);
144        assert!(array.validate().is_err());
145        let mut future = request;
146        future.schema_version = 2;
147        assert!(future.validate().is_err());
148    }
149}