use std::path::Path;
use std::sync::Arc;
#[cfg(feature = "http-server")]
use lean_ctx_protocol::context_gateway::EgressTransportObservationV1;
use lean_ctx_protocol::context_gateway::{
ClassificationV1, ContextDecisionReceiptV1, ContextDecisionV1, ContextDispositionV1,
DestinationLocalityV1, DestinationV1, EgressAdmissionOutcomeV1, EgressAdmissionReceiptV1,
ReasonCodeV1,
};
use lean_ctx_protocol::{ProtocolReference, Sha256Digest};
use serde_json::Value;
use super::capture::{self, AdmissionCapture, CallIdentity, Delivered};
use super::receipt_store::keyed::KeyedRefs;
use super::{
AdmissionCounts, AdmissionPolicy, admit, admit_uninspectable, fingerprint_digest, reason,
};
pub const PROXY_RECEIPT_KEY: &str = "<proxy>";
#[derive(Debug, Clone, Copy)]
pub enum EgressCaller<'a> {
Proxy,
Host {
project_root: &'a str,
operation: &'a str,
},
}
impl EgressCaller<'_> {
fn receipt_index(&self) -> &str {
match self {
Self::Proxy => PROXY_RECEIPT_KEY,
Self::Host { project_root, .. } => project_root,
}
}
fn tool(&self) -> &str {
match self {
Self::Proxy => "proxy",
Self::Host { operation, .. } => operation,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct EgressTarget<'a> {
pub provider: &'a str,
pub model: Option<&'a str>,
pub upstream_base: &'a str,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TrustedDestinationBinding<'a> {
pub provider_id: Option<&'a str>,
pub local: Option<bool>,
pub account_ref: Option<&'a str>,
pub region: Option<&'a str>,
}
#[must_use]
pub fn destination(target: &EgressTarget<'_>) -> DestinationV1 {
destination_with_binding(target, TrustedDestinationBinding::default())
}
#[must_use]
pub fn destination_with_binding(
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
) -> DestinationV1 {
let reference = |raw: &str| {
ProtocolReference::new(raw.to_owned()).ok().or_else(|| {
let digest = blake3::hash(raw.as_bytes()).to_hex();
ProtocolReference::new(digest[..32].to_owned()).ok()
})
};
DestinationV1 {
provider: reference(
&binding
.provider_id
.unwrap_or(target.provider)
.to_ascii_lowercase(),
)
.unwrap_or_else(|| ProtocolReference::new("provider").expect("valid reference")),
model: target.model.and_then(reference),
locality: binding.local.map_or_else(
|| locality(target.upstream_base),
|local| {
if local {
DestinationLocalityV1::Local
} else {
DestinationLocalityV1::Remote
}
},
),
organization_managed: false,
account_ref: binding.account_ref.and_then(reference),
region: binding.region.and_then(reference),
}
}
fn locality(upstream_base: &str) -> DestinationLocalityV1 {
let Some(host) = reqwest::Url::parse(upstream_base)
.ok()
.and_then(|url| url.host_str().map(str::to_ascii_lowercase))
else {
return DestinationLocalityV1::Unknown;
};
let bare = host.trim_start_matches('[').trim_end_matches(']');
let local = match bare.parse::<std::net::IpAddr>() {
Ok(ip) => ip.is_loopback(),
Err(_) => bare == "localhost" || bare.ends_with(".localhost"),
};
if local {
DestinationLocalityV1::Local
} else {
DestinationLocalityV1::Remote
}
}
#[derive(Debug)]
pub enum EgressBody {
Unchanged,
Rewritten(Value),
Refused(String),
}
#[derive(Debug)]
pub struct EgressOutcome {
pub body: EgressBody,
pub classification: Option<ClassificationV1>,
capture: Option<Arc<AdmissionCapture>>,
destination: DestinationV1,
policy_fingerprint: Vec<u8>,
receipt_required: bool,
refs: KeyedRefs,
}
#[derive(Debug, Clone, Default)]
pub struct EgressEvidence {
pub decision_receipt: Option<ContextDecisionReceiptV1>,
pub admission: Option<EgressAdmissionReceiptV1>,
pub admission_digest: Option<Sha256Digest>,
}
impl EgressOutcome {
fn passthrough(destination: DestinationV1, policy_fingerprint: Vec<u8>) -> Self {
Self {
body: EgressBody::Unchanged,
classification: None,
capture: None,
destination,
policy_fingerprint,
receipt_required: false,
refs: KeyedRefs::default(),
}
}
#[cfg(any(test, feature = "http-server"))]
pub fn receipt_required(&self) -> bool {
self.receipt_required
}
#[must_use]
#[cfg(any(test, feature = "http-server"))]
pub fn for_principal(mut self, principal_ref: Option<String>) -> Self {
self.refs.principal = principal_ref;
self
}
#[must_use]
#[cfg(any(test, feature = "http-server"))]
pub fn with_request_head(mut self, head_ref: Option<String>) -> Self {
self.refs.request_head = head_ref;
self
}
pub fn policy_epoch_current_for_project(&self, project_fingerprint: &[u8]) -> bool {
let policy = AdmissionPolicy::from_config(&crate::core::config::Config::load_arc());
let current = combined_policy_fingerprint(&policy.fingerprint(), project_fingerprint);
self.policy_fingerprint == current
}
}
#[path = "egress_walk.rs"]
mod walk;
pub use walk::{PartKind, SealedPart, SealedRequest, SealedVerdict};
use walk::{Position, SealedWalk, Walk, Wire};
pub fn with_sealed_resolver<T>(
resolver: Box<dyn Fn(&SealedRequest<'_>) -> Vec<SealedVerdict>>,
admission: impl FnOnce() -> T,
) -> T {
walk::with_resolver(resolver, admission)
}
#[must_use]
pub fn admit_request(doc: &Value, target: &EgressTarget<'_>) -> EgressOutcome {
admit_request_inner(doc, target, None, TrustedDestinationBinding::default())
}
#[must_use]
pub fn admit_request_with_binding(
doc: &Value,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
) -> EgressOutcome {
admit_request_inner(doc, target, None, binding)
}
pub fn admit_request_for_project(
doc: &Value,
target: &EgressTarget<'_>,
project_fingerprint: &[u8],
) -> EgressOutcome {
admit_request_inner(
doc,
target,
Some(project_fingerprint),
TrustedDestinationBinding::default(),
)
}
#[cfg(any(test, feature = "http-server"))]
pub fn admit_request_for_project_with_binding(
doc: &Value,
target: &EgressTarget<'_>,
project_fingerprint: &[u8],
binding: TrustedDestinationBinding<'_>,
) -> EgressOutcome {
admit_request_inner(doc, target, Some(project_fingerprint), binding)
}
fn withholds_everything(capture: &AdmissionCapture) -> bool {
let decided: Vec<_> = capture
.itemized()
.into_iter()
.map(|(decision, _)| decision)
.filter(|decision| !capture::is_note(decision))
.collect();
!decided.is_empty()
&& capture.counted_only() == 0
&& decided
.iter()
.all(|decision| !decision.disposition.delivers_content())
}
fn record_policy_only(
capture: &AdmissionCapture,
doc: &Value,
policy: &AdmissionPolicy,
policy_fingerprint: &[u8],
) {
capture.record_unsourced(
fingerprint_digest(doc.to_string().as_bytes()),
policy.gateway.mode,
fingerprint_digest(policy_fingerprint),
);
}
fn admit_request_inner(
doc: &Value,
target: &EgressTarget<'_>,
project_fingerprint: Option<&[u8]>,
binding: TrustedDestinationBinding<'_>,
) -> EgressOutcome {
let destination = destination_with_binding(target, binding);
let policy = AdmissionPolicy::from_config(&crate::core::config::Config::load_arc());
#[cfg(test)]
failpoint::after_snapshot();
let (project_policy, org_observation) = match project_fingerprint {
Some(_) => crate::core::policy::runtime::active_observed(),
None => (None, None),
};
let project_policy_active = project_policy.is_some();
let destination_policy_active = policy.gateway.has_destination_policy();
let gateway_fingerprint = policy.fingerprint();
let fingerprint = project_fingerprint.map_or_else(
|| gateway_fingerprint.clone(),
|project| combined_policy_fingerprint(&gateway_fingerprint, project),
);
let gateway_enabled = policy.enabled_effective();
let org_observed = org_observation.is_some();
if !gateway_enabled && !project_policy_active && !destination_policy_active && !org_observed {
return EgressOutcome::passthrough(destination, fingerprint);
}
let capture = AdmissionCapture::new();
capture.bind_policy(policy.gateway.mode, fingerprint_digest(&fingerprint));
if let Some(active) = &project_policy {
capture.note_pack(&active.resolved.name, &active.resolved.version);
}
if project_fingerprint.is_some() {
capture.note_org_policy(org_observation);
}
let policy_fingerprint = fingerprint.clone();
if let Some(codes) = policy.config_refusal_codes() {
capture.record(
&ContextDecisionV1 {
object: fingerprint_digest(doc.to_string().as_bytes()),
disposition: ContextDispositionV1::Deny,
reason_codes: codes.iter().map(|code| reason(code)).collect(),
signals: Vec::new(),
required_transformations: Vec::new(),
},
AdmissionCounts::default(),
);
return EgressOutcome {
body: EgressBody::Refused(format!(
"request withheld by the context gateway ({})",
codes.join(", ")
)),
classification: None,
capture: Some(capture),
destination,
policy_fingerprint,
receipt_required: true,
refs: KeyedRefs::default(),
};
}
if destination_policy_active {
let allowed = policy.gateway.allows_destination(&destination);
let object = fingerprint_digest(doc.to_string().as_bytes());
capture.record(
&ContextDecisionV1 {
object,
disposition: if allowed {
ContextDispositionV1::Allow
} else {
ContextDispositionV1::Deny
},
reason_codes: vec![reason(if allowed {
"destination.constraint_satisfied"
} else {
"destination.constraint_unsatisfied"
})],
signals: Vec::new(),
required_transformations: Vec::new(),
},
AdmissionCounts::default(),
);
if !allowed {
return EgressOutcome {
body: EgressBody::Refused(
"request withheld by the context gateway (destination.constraint_unsatisfied)"
.to_owned(),
),
classification: None,
capture: Some(capture),
destination,
policy_fingerprint,
receipt_required: true,
refs: KeyedRefs::default(),
};
}
}
if !gateway_enabled && !project_policy_active {
if org_observed && capture.is_empty() {
record_policy_only(&capture, doc, &policy, &policy_fingerprint);
}
return EgressOutcome {
body: EgressBody::Unchanged,
classification: None,
capture: Some(capture),
destination,
policy_fingerprint,
receipt_required: true,
refs: KeyedRefs::default(),
};
}
let mut admitted = doc.clone();
let mut walk = Walk {
policy: &policy,
fingerprint,
capture: &capture,
remote: destination.locality != DestinationLocalityV1::Local,
mandatory: policy.detectors_mandatory(),
project: project_policy,
wire: Wire::of(target.provider),
anthropic_safeguards: target.provider.eq_ignore_ascii_case("anthropic"),
refused: None,
rewritten: false,
classification: None,
nodes: 0,
partial: false,
sealed: SealedWalk::default(),
provider: destination.provider.as_str().to_owned(),
at: walk::Location::default(),
withheld_classification: None,
};
walk.walk(&mut admitted, Position::Root, false, 0);
walk.settle_sealed();
if walk.refused.is_none()
&& walk
.project
.as_ref()
.is_some_and(|active| !active.within_horizon())
{
walk.refused = Some("request withheld by the context gateway (policy.expired)".into());
}
if org_observed && walk.refused.is_none() && capture.is_empty() {
record_policy_only(&capture, doc, &policy, &policy_fingerprint);
}
let body = match (walk.refused.take(), walk.rewritten) {
(Some(refusal), _) => EgressBody::Refused(refusal),
(None, true) if withholds_everything(&capture) => EgressBody::Refused(format!(
"request withheld by the context gateway ({})",
capture
.deny_reason_codes()
.iter()
.map(ReasonCodeV1::as_str)
.collect::<Vec<_>>()
.join(", ")
)),
(None, true) => EgressBody::Rewritten(admitted),
(None, false) => EgressBody::Unchanged,
};
let classification = match (&body, walk.withheld_classification) {
(EgressBody::Refused(_), Some(withheld)) => Some(
walk.classification
.map_or(withheld, |seen| seen.join(withheld)),
),
_ => walk.classification,
};
EgressOutcome {
body,
classification,
capture: Some(capture),
destination,
policy_fingerprint,
receipt_required: super::receipt_store::mandatory_under(&policy, destination_policy_active)
|| org_observed,
refs: KeyedRefs::default(),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PolicyEpoch {
fingerprint: EpochFingerprint,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum EpochFingerprint {
Bytes(Vec<u8>),
Digest([u8; 32]),
}
impl PolicyEpoch {
#[must_use]
pub fn digest(&self) -> [u8; 32] {
match &self.fingerprint {
EpochFingerprint::Bytes(bytes) => epoch_digest(bytes),
EpochFingerprint::Digest(digest) => *digest,
}
}
#[must_use]
pub fn from_digest(digest: [u8; 32]) -> Self {
Self {
fingerprint: EpochFingerprint::Digest(digest),
}
}
#[must_use]
pub fn is_current(&self, project_fingerprint: Option<&[u8]>) -> bool {
let policy = AdmissionPolicy::from_config(&crate::core::config::Config::load_arc());
self.matches(&policy, project_fingerprint)
}
pub fn matches(&self, policy: &AdmissionPolicy, project_fingerprint: Option<&[u8]>) -> bool {
let gateway = policy.fingerprint();
let current = project_fingerprint.map_or_else(
|| gateway.clone(),
|project| combined_policy_fingerprint(&gateway, project),
);
match &self.fingerprint {
EpochFingerprint::Bytes(bytes) => *bytes == current,
EpochFingerprint::Digest(digest) => *digest == epoch_digest(¤t),
}
}
}
fn epoch_digest(bytes: &[u8]) -> [u8; 32] {
let mut hash = blake3::Hasher::new();
hash.update(b"leanctx-policy-epoch-v1\0");
hash.update(bytes);
*hash.finalize().as_bytes()
}
impl EgressOutcome {
#[cfg(any(test, feature = "test-support"))]
pub fn org_evidence(&self) -> Option<super::capture::OrgEvidence> {
self.capture.as_ref().map(|capture| capture.org_policy())
}
#[must_use]
pub fn policy_epoch(&self) -> PolicyEpoch {
PolicyEpoch {
fingerprint: EpochFingerprint::Bytes(self.policy_fingerprint.clone()),
}
}
#[must_use]
pub fn join(self, later: Self) -> Self {
if self.policy_fingerprint != later.policy_fingerprint {
return later.refuse(&[reason("policy.changed")]);
}
let capture = match (self.capture, later.capture) {
(Some(earlier), Some(final_capture)) => {
let joined = AdmissionCapture::new();
joined.absorb(&earlier);
joined.absorb(&final_capture);
if let Some((mode, digest)) = final_capture
.bound_policy()
.or_else(|| earlier.bound_policy())
{
joined.bind_policy(mode, digest);
}
Some(joined)
}
(earlier, final_capture) => final_capture.or(earlier),
};
let classification = match (self.classification, later.classification) {
(Some(earlier), Some(final_class)) => Some(earlier.join(final_class)),
(earlier, final_class) => final_class.or(earlier),
};
let body = if matches!(self.body, EgressBody::Refused(_))
&& !matches!(later.body, EgressBody::Refused(_))
{
self.body
} else {
later.body
};
Self {
body,
classification,
capture,
destination: later.destination,
policy_fingerprint: later.policy_fingerprint,
receipt_required: self.receipt_required || later.receipt_required,
refs: later.refs.or(self.refs),
}
}
#[must_use]
pub fn refusal_codes(&self) -> Vec<ReasonCodeV1> {
match (&self.body, &self.capture) {
(EgressBody::Refused(_), Some(capture)) => capture.deny_reason_codes(),
_ => Vec::new(),
}
}
#[must_use]
pub fn refused_blocks(&self) -> Vec<String> {
const MAX: usize = 8;
let (EgressBody::Refused(_), Some(capture)) = (&self.body, &self.capture) else {
return Vec::new();
};
let mut ids: Vec<String> = Vec::new();
for (decision, _) in capture.itemized() {
if decision.disposition.delivers_content() {
continue;
}
if let Some(id) = super::withheld::block_id(&decision.object)
&& !ids.contains(&id)
&& super::withheld::lookup(&id).is_some()
{
ids.push(id);
if ids.len() == MAX {
break;
}
}
}
ids
}
#[must_use]
pub fn refuse(mut self, reason_codes: &[ReasonCodeV1]) -> Self {
let capture = self.capture.take().unwrap_or_else(|| {
let capture = AdmissionCapture::new();
let policy = AdmissionPolicy::from_config(&crate::core::config::Config::load_arc());
capture.bind_policy(
policy.gateway.mode,
fingerprint_digest(&self.policy_fingerprint),
);
capture
});
capture.record(
&ContextDecisionV1 {
object: fingerprint_digest(b"egress-refused-after-admission"),
disposition: ContextDispositionV1::Deny,
reason_codes: reason_codes.to_vec(),
signals: Vec::new(),
required_transformations: Vec::new(),
},
AdmissionCounts::default(),
);
let codes: Vec<&str> = reason_codes.iter().map(ReasonCodeV1::as_str).collect();
self.body = EgressBody::Refused(format!(
"request withheld by the context gateway ({})",
codes.join(", ")
));
self.capture = Some(capture);
self.receipt_required = true;
self
}
#[must_use]
pub fn refuse_with(self, reason_codes: &[&str]) -> Self {
let codes: Vec<ReasonCodeV1> = reason_codes.iter().map(|code| reason(code)).collect();
self.refuse(&codes)
}
#[must_use]
pub fn note_with(self, reason_codes: &[&str]) -> Self {
if let Some(capture) = self.capture.as_ref() {
for code in reason_codes {
capture.record(
&ContextDecisionV1 {
object: capture::note_object(),
disposition: ContextDispositionV1::Allow,
reason_codes: vec![reason(code)],
signals: Vec::new(),
required_transformations: Vec::new(),
},
AdmissionCounts::default(),
);
}
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextVerdict {
Delivered,
Redacted {
text: String,
reason_codes: Vec<ReasonCodeV1>,
},
Withheld { reason_codes: Vec<ReasonCodeV1> },
}
#[must_use]
pub fn admit_text(
text: &str,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
) -> TextVerdict {
let policy = AdmissionPolicy::from_config(&crate::core::config::Config::load_arc());
if let Some(codes) = policy.config_refusal_codes() {
return TextVerdict::Withheld {
reason_codes: codes.iter().map(|code| reason(code)).collect(),
};
}
let destination = destination_with_binding(target, binding);
if policy.gateway.has_destination_policy() && !policy.gateway.allows_destination(&destination) {
return TextVerdict::Withheld {
reason_codes: vec![reason("destination.constraint_unsatisfied")],
};
}
if text.is_empty() || !policy.enabled_effective() {
return TextVerdict::Delivered;
}
let admission = admit(text, None, &policy);
let mut reason_codes = admission.decision.reason_codes;
match admission.text {
None => TextVerdict::Withheld { reason_codes },
Some(_)
if destination.locality != DestinationLocalityV1::Local
&& admission.classification >= ClassificationV1::Restricted =>
{
reason_codes.push(reason("destination.remote_restricted"));
TextVerdict::Withheld { reason_codes }
}
Some(delivered) if delivered == text => TextVerdict::Delivered,
Some(delivered) => TextVerdict::Redacted {
text: delivered,
reason_codes,
},
}
}
fn combined_policy_fingerprint(gateway: &[u8], project: &[u8]) -> Vec<u8> {
let mut combined = b"lean-ctx-egress-project-policy-v1\0".to_vec();
combined.extend_from_slice(&(gateway.len() as u64).to_be_bytes());
combined.extend_from_slice(gateway);
combined.extend_from_slice(&(project.len() as u64).to_be_bytes());
combined.extend_from_slice(project);
combined
}
#[must_use]
pub fn admit_opaque(bytes: usize, target: &EgressTarget<'_>) -> EgressOutcome {
admit_opaque_with_binding(bytes, target, TrustedDestinationBinding::default())
}
#[must_use]
pub fn admit_opaque_with_binding(
bytes: usize,
target: &EgressTarget<'_>,
binding: TrustedDestinationBinding<'_>,
) -> EgressOutcome {
admit_opaque_inner(bytes, target, None, binding)
}
#[must_use]
pub fn admit_opaque_for_project_with_binding(
bytes: usize,
target: &EgressTarget<'_>,
project_fingerprint: &[u8],
binding: TrustedDestinationBinding<'_>,
) -> EgressOutcome {
admit_opaque_inner(bytes, target, Some(project_fingerprint), binding)
}
fn admit_opaque_inner(
bytes: usize,
target: &EgressTarget<'_>,
project_fingerprint: Option<&[u8]>,
binding: TrustedDestinationBinding<'_>,
) -> EgressOutcome {
let destination = destination_with_binding(target, binding);
let policy = AdmissionPolicy::from_config(&crate::core::config::Config::load_arc());
let gateway_fingerprint = policy.fingerprint();
let policy_fingerprint = project_fingerprint.map_or_else(
|| gateway_fingerprint.clone(),
|project| combined_policy_fingerprint(&gateway_fingerprint, project),
);
let destination_policy_active = policy.gateway.has_destination_policy();
let gateway_enabled = policy.enabled_effective();
let held = match project_fingerprint {
Some(_) => crate::core::policy::runtime::held_observation(),
None => crate::core::policy::runtime::HeldObservation::NotHeld,
};
let org_observed = matches!(
held,
crate::core::policy::runtime::HeldObservation::Held(Some(_))
);
if (bytes == 0 || !gateway_enabled) && !destination_policy_active && !org_observed {
return EgressOutcome::passthrough(destination, policy_fingerprint);
}
let capture = AdmissionCapture::new();
capture.bind_policy(policy.gateway.mode, fingerprint_digest(&policy_fingerprint));
match held {
crate::core::policy::runtime::HeldObservation::Held(observation) => {
capture.note_org_policy(observation);
}
crate::core::policy::runtime::HeldObservation::NotHeld => {
capture.note_org_policy_unavailable();
}
}
let opaque_object = || fingerprint_digest(format!("opaque-egress:{bytes}").as_bytes());
if destination_policy_active {
let allowed = policy.gateway.allows_destination(&destination);
let object = opaque_object();
capture.record(
&ContextDecisionV1 {
object,
disposition: if allowed {
ContextDispositionV1::Allow
} else {
ContextDispositionV1::Deny
},
reason_codes: vec![reason(if allowed {
"destination.constraint_satisfied"
} else {
"destination.constraint_unsatisfied"
})],
signals: Vec::new(),
required_transformations: Vec::new(),
},
AdmissionCounts::default(),
);
if !allowed {
return EgressOutcome {
body: EgressBody::Refused(
"request withheld by the context gateway (destination.constraint_unsatisfied)"
.to_owned(),
),
classification: None,
capture: Some(capture),
destination,
policy_fingerprint,
receipt_required: true,
refs: KeyedRefs::default(),
};
}
}
if !gateway_enabled || bytes == 0 {
if org_observed && capture.is_empty() {
capture.record_unsourced(
opaque_object(),
policy.gateway.mode,
fingerprint_digest(&policy_fingerprint),
);
}
return EgressOutcome {
body: EgressBody::Unchanged,
classification: None,
capture: Some(capture),
destination,
policy_fingerprint,
receipt_required: true,
refs: KeyedRefs::default(),
};
}
let mut admission =
admit_uninspectable(Some(Path::new("egress.opaque")), bytes as u64, &policy);
admission
.decision
.reason_codes
.push(reason("egress.opaque_payload"));
capture.record(&admission.decision, admission.counts);
let body = if admission.text.is_some() {
EgressBody::Unchanged
} else {
EgressBody::Refused(
"request withheld by the context gateway (egress.opaque_payload)".to_owned(),
)
};
EgressOutcome {
body,
classification: None,
capture: Some(capture),
destination,
policy_fingerprint,
receipt_required: super::receipt_store::mandatory_under(&policy, destination_policy_active)
|| org_observed,
refs: KeyedRefs::default(),
}
}
fn finish_with_persistence(
outcome: &EgressOutcome,
forwarded: Option<&[u8]>,
original_bytes: usize,
agent_id: Option<&str>,
caller: EgressCaller<'_>,
) -> Result<Option<(ContextDecisionReceiptV1, Option<Sha256Digest>)>, &'static str> {
let Some(capture) = outcome.capture.as_ref() else {
return Ok(None);
};
if capture.is_empty() {
return if outcome.receipt_required {
Err(RECEIPT_UNAVAILABLE)
} else {
Ok(None)
};
}
capture.set_original_tokens((original_bytes / 4) as u64);
let text = forwarded.map(String::from_utf8_lossy).unwrap_or_default();
let delivered = Delivered {
text: &text,
is_error: forwarded.is_none(),
tokens: (forwarded.map_or(0, <[u8]>::len) / 4) as u64,
};
let identity = CallIdentity {
agent_id,
destination: Some(outcome.destination.clone()),
};
let presenter_ref = crate::core::archive::presented::shown_presenter_reference();
let Some(mut receipt) =
capture::finish_for_presenter(capture, &identity, presenter_ref.as_deref(), &delivered)
else {
return if outcome.receipt_required {
Err(RECEIPT_UNAVAILABLE)
} else {
Ok(None)
};
};
if receipt.final_context.is_some() {
receipt.final_context = forwarded.map(fingerprint_digest);
}
if receipt.validate().is_err() {
return if outcome.receipt_required {
Err(RECEIPT_UNAVAILABLE)
} else {
Ok(None)
};
}
let tool = caller.tool();
let audit_agent = agent_id.unwrap_or(tool);
let tally = capture.record_security(tool, audit_agent);
let persisted_digest = match super::receipt_store::persist(
&receipt,
caller.receipt_index(),
capture.task_scope().as_deref(),
)
.and_then(|digest| {
if outcome.receipt_required {
use super::receipt_store::trace::Delivery;
let wire = forwarded.map(fingerprint_digest);
let delivery = match caller {
EgressCaller::Proxy => {
Delivery::proxy(outcome.destination.provider.as_str(), wire, capture.pack())
}
EgressCaller::Host { operation, .. } => {
Delivery::host_egress(operation, wire, capture.pack())
}
}
.under_org_policy(capture.org_policy())
.with_refs(outcome.refs.clone());
let sealing = super::receipt_store::Sealing {
capture,
delivery: &delivery,
delivered: &delivered,
tool,
audit_agent,
};
super::receipt_store::seal(&digest, &receipt, &sealing)?;
} else if !tally.is_empty() {
crate::core::security_events::anchor_receipt(tool, audit_agent, digest.hex());
}
Ok(digest)
}) {
Ok(digest) => Some(digest),
Err(error) => {
tracing::warn!("proxy gateway receipt not persisted: {error}");
if outcome.receipt_required {
return Err(RECEIPT_UNAVAILABLE);
}
None
}
};
Ok(Some((receipt, persisted_digest)))
}
#[cfg(test)]
pub fn finish(
outcome: &EgressOutcome,
forwarded: Option<&[u8]>,
original_bytes: usize,
agent_id: Option<&str>,
) -> Result<Option<ContextDecisionReceiptV1>, &'static str> {
finish_with_persistence(
outcome,
forwarded,
original_bytes,
agent_id,
EgressCaller::Proxy,
)
.map(|evidence| evidence.map(|(receipt, _)| receipt))
}
#[cfg(feature = "http-server")]
pub fn finish_admission(
outcome: &EgressOutcome,
forwarded: Option<&[u8]>,
original_bytes: usize,
agent_id: Option<&str>,
) -> Result<Option<EgressEvidence>, &'static str> {
finish_admission_for(
outcome,
forwarded,
original_bytes,
agent_id,
EgressCaller::Proxy,
)
}
pub fn finish_admission_for(
outcome: &EgressOutcome,
forwarded: Option<&[u8]>,
original_bytes: usize,
agent_id: Option<&str>,
caller: EgressCaller<'_>,
) -> Result<Option<EgressEvidence>, &'static str> {
let receipt_required = outcome.receipt_required;
let dispatchable = !matches!(outcome.body, EgressBody::Refused(_));
let effective_forwarded = dispatchable.then_some(forwarded).flatten();
let Some((decision_receipt, decision_digest)) = finish_with_persistence(
outcome,
effective_forwarded,
original_bytes,
agent_id,
caller,
)?
else {
return Ok(None);
};
let mut evidence = EgressEvidence {
decision_receipt: Some(decision_receipt.clone()),
admission: None,
admission_digest: None,
};
let Some(decision_digest) = decision_digest else {
return Ok(Some(evidence));
};
let (admission_outcome, prepared_bytes_digest) = match (&outcome.body, effective_forwarded) {
(EgressBody::Refused(_), _) => (EgressAdmissionOutcomeV1::Withheld, None),
(_, Some(bytes)) => (
EgressAdmissionOutcomeV1::Prepared,
Some(fingerprint_digest(bytes)),
),
(_, None) => (EgressAdmissionOutcomeV1::Failed, None),
};
let receipt_id =
ProtocolReference::new(format!("egr-{}", decision_receipt.receipt_id.as_str()))
.map_err(|_| RECEIPT_UNAVAILABLE)?;
let admission = EgressAdmissionReceiptV1 {
schema_version: lean_ctx_protocol::V1_SCHEMA_VERSION,
receipt_id,
decision_receipt_digest: Some(decision_digest),
outcome: admission_outcome,
prepared_bytes_digest,
};
match super::receipt_store::persist_admission(&admission) {
Ok(digest) => {
evidence.admission = Some(admission);
evidence.admission_digest = Some(digest);
}
Err(error) => {
tracing::warn!("proxy gateway admission receipt not persisted: {error}");
if receipt_required {
return Err(RECEIPT_UNAVAILABLE);
}
}
}
Ok(Some(evidence))
}
pub const RECEIPT_UNAVAILABLE: &str = "required context gateway receipt unavailable";
#[cfg(feature = "http-server")]
fn receipt_unavailable(required: bool) -> Result<Option<ContextDecisionReceiptV1>, &'static str> {
if required {
Err(RECEIPT_UNAVAILABLE)
} else {
Ok(None)
}
}
#[cfg(feature = "http-server")]
pub async fn finish_admission_off_runtime(
outcome: EgressOutcome,
forwarded: Option<Vec<u8>>,
original_bytes: usize,
agent_id: Option<String>,
) -> Result<Option<EgressEvidence>, &'static str> {
let receipt_required = outcome.receipt_required;
match tokio::task::spawn_blocking(move || {
finish_admission(
&outcome,
forwarded.as_deref(),
original_bytes,
agent_id.as_deref(),
)
})
.await
{
Ok(result) => result,
Err(error) => {
tracing::warn!("proxy gateway admission task failed: {error}");
receipt_unavailable(receipt_required).map(|_| None)
}
}
}
#[cfg(feature = "http-server")]
pub fn append_engine_observation(
observation: &EgressTransportObservationV1,
) -> Result<(), &'static str> {
super::receipt_store::append_engine_observation(observation)
}
pub fn record_host_observation(
request: &lean_ctx_protocol::EngineEgressHostReportRequestV1,
) -> Result<lean_ctx_protocol::EngineEgressHostReportResponseV1, &'static str> {
request
.validate()
.map_err(|_| "host egress report is invalid")?;
let (observation_digest, observation) = super::receipt_store::append_host_observation(
&request.admission_receipt_digest,
request.attempt,
request.transport,
request.result,
request.http_status,
request.failure,
)?;
Ok(lean_ctx_protocol::EngineEgressHostReportResponseV1 {
schema_version: lean_ctx_protocol::ENGINE_EGRESS_SCHEMA_VERSION,
observation_digest,
observation,
})
}
pub fn load_egress_for_decision(
decision_digest: &Sha256Digest,
) -> Result<Option<super::receipt_store::EgressLifecycleView>, &'static str> {
super::receipt_store::load_egress_for_decision(decision_digest)
}
#[must_use]
pub fn admitted_copy(doc: &Value, target: &EgressTarget<'_>) -> Option<Value> {
match admit_request(doc, target).body {
EgressBody::Unchanged => Some(doc.clone()),
EgressBody::Rewritten(admitted) => Some(admitted),
EgressBody::Refused(_) => None,
}
}
#[cfg(test)]
pub(super) mod failpoint {
use std::cell::RefCell;
type Hook = Box<dyn FnMut()>;
thread_local! {
static AFTER_SNAPSHOT: RefCell<Option<Hook>> = const { RefCell::new(None) };
}
pub(in crate::core::context_admission) fn arm(hook: impl FnMut() + 'static) {
AFTER_SNAPSHOT.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
}
pub(super) fn after_snapshot() {
let hook = AFTER_SNAPSHOT.with(|slot| slot.borrow_mut().take());
if let Some(mut hook) = hook {
hook();
}
}
thread_local! {
static AFTER_FIRST_STRING: RefCell<Option<Hook>> = const { RefCell::new(None) };
}
pub(in crate::core::context_admission) fn arm_after_first_string(hook: impl FnMut() + 'static) {
AFTER_FIRST_STRING.with(|slot| *slot.borrow_mut() = Some(Box::new(hook)));
}
pub(super) fn after_first_string() {
let hook = AFTER_FIRST_STRING.with(|slot| slot.borrow_mut().take());
if let Some(mut hook) = hook {
hook();
}
}
}