pub mod capture;
pub mod carrier;
mod clean;
pub(crate) mod cross_line;
mod detectors;
pub mod egress;
pub mod hud;
mod notes;
pub mod origin;
pub mod provider;
pub mod receipt_store;
pub mod recovery;
pub(crate) mod semantic;
pub mod semantic_deferred;
pub mod stored;
pub mod stores;
pub mod withheld;
use std::path::Path;
use lean_ctx_protocol::Sha256Digest;
use lean_ctx_protocol::context_gateway::{
ClassificationV1, ContextDecisionV1, ContextDispositionV1, CoverageKindV1,
DestinationConstraintV1, DestinationLocalityV1, DestinationV1, DetectorSignalV1, GatewayModeV1,
ReasonCodeV1, TransformationKindV1, TrustLevelV1,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::core::config::SecretDetectionConfig;
use crate::core::input_filters::FilterAction;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ContextGatewayConfig {
pub enabled: bool,
pub mode: GatewayModeV1,
pub secrets: FilterAction,
pub pii: FilterAction,
pub injection: FilterAction,
pub classification: FilterAction,
pub max_inspected_bytes: usize,
pub detector_timeout_ms: u64,
pub destination_constraints: Vec<DestinationConstraintV1>,
pub allowed_destinations: Vec<DestinationV1>,
pub hud: hud::HudPlacement,
}
pub const DEFAULT_MAX_INSPECTED_BYTES: usize = 8 * 1024 * 1024;
pub const DEFAULT_DETECTOR_TIMEOUT_MS: u64 = 2_000;
impl Default for ContextGatewayConfig {
fn default() -> Self {
Self {
enabled: true,
mode: GatewayModeV1::Developer,
secrets: FilterAction::Redact,
pii: FilterAction::Redact,
injection: FilterAction::Warn,
classification: FilterAction::Warn,
max_inspected_bytes: DEFAULT_MAX_INSPECTED_BYTES,
detector_timeout_ms: DEFAULT_DETECTOR_TIMEOUT_MS,
destination_constraints: Vec::new(),
allowed_destinations: Vec::new(),
hud: hud::HudPlacement::Auto,
}
}
}
impl ContextGatewayConfig {
#[must_use]
pub fn enabled_effective(&self) -> bool {
self.enabled_under(EnvSwitch::read())
}
#[must_use]
pub(crate) fn enabled_under(&self, env: EnvSwitch) -> bool {
match self.kill_switch_under(env) {
KillSwitch::Unset | KillSwitch::Ignored => self.enabled,
KillSwitch::ForcedOn => true,
KillSwitch::Honoured => false,
}
}
#[must_use]
pub fn kill_switch(&self) -> KillSwitch {
self.kill_switch_under(EnvSwitch::read())
}
fn kill_switch_under(&self, env: EnvSwitch) -> KillSwitch {
match env {
EnvSwitch::Unset => return KillSwitch::Unset,
EnvSwitch::On => return KillSwitch::ForcedOn,
EnvSwitch::Off => {}
}
if self.mode == GatewayModeV1::Developer {
KillSwitch::Honoured
} else {
static REPORTED: std::sync::Once = std::sync::Once::new();
REPORTED.call_once(|| {
tracing::warn!(
"LEAN_CTX_CONTEXT_GATEWAY=off ignored: the context gateway runs in {:?} mode",
self.mode
);
});
KillSwitch::Ignored
}
}
#[must_use]
pub(crate) fn has_destination_policy(&self) -> bool {
!self.destination_constraints.is_empty() || !self.allowed_destinations.is_empty()
}
#[must_use]
pub(crate) fn allows_destination(&self, actual: &DestinationV1) -> bool {
if actual.locality == DestinationLocalityV1::Unknown
|| !actual.satisfies_all(&self.destination_constraints)
{
return false;
}
self.allowed_destinations.is_empty()
|| self.allowed_destinations.iter().any(|expected| {
expected.locality != DestinationLocalityV1::Unknown
&& expected.provider == actual.provider
&& expected.locality == actual.locality
&& expected.organization_managed == actual.organization_managed
&& expected
.model
.as_ref()
.is_none_or(|reference| actual.model.as_ref() == Some(reference))
&& expected
.account_ref
.as_ref()
.is_none_or(|reference| actual.account_ref.as_ref() == Some(reference))
&& expected
.region
.as_ref()
.is_none_or(|reference| actual.region.as_ref() == Some(reference))
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EnvSwitch {
Unset,
Off,
On,
}
impl EnvSwitch {
pub(crate) fn read() -> Self {
match std::env::var("LEAN_CTX_CONTEXT_GATEWAY") {
Err(_) => Self::Unset,
Ok(value) if matches!(value.trim(), "0" | "false" | "off") => Self::Off,
Ok(_) => Self::On,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KillSwitch {
Unset,
ForcedOn,
Honoured,
Ignored,
}
#[derive(Debug, Clone)]
pub struct AdmissionPolicy {
pub gateway: ContextGatewayConfig,
pub secret_patterns: SecretDetectionConfig,
pub(crate) semantic: Option<semantic::SemanticDetector>,
load_failure: Option<crate::core::config::ConfigLoadFailure>,
env_switch: EnvSwitch,
}
impl AdmissionPolicy {
#[must_use]
pub fn from_config(config: &crate::core::config::Config) -> Self {
Self {
gateway: config.context_gateway.clone(),
secret_patterns: config.secret_detection.clone(),
semantic: semantic::SemanticDetector::for_snapshot(config),
load_failure: config.load_failure,
env_switch: EnvSwitch::read(),
}
}
#[must_use]
pub fn builtin(gateway: ContextGatewayConfig, secret_patterns: SecretDetectionConfig) -> Self {
Self {
gateway,
secret_patterns,
semantic: None,
load_failure: None,
env_switch: EnvSwitch::read(),
}
}
#[must_use]
pub fn config_unloadable(&self) -> bool {
self.load_failure.is_some()
}
pub(crate) fn config_refusal_codes(&self) -> Option<[&'static str; 2]> {
self.load_failure
.map(|failure| ["gateway.config_unloadable", failure.code()])
}
#[must_use]
pub fn enabled_effective(&self) -> bool {
self.config_unloadable() || self.gateway_switch()
}
fn gateway_switch(&self) -> bool {
self.gateway.enabled_under(self.env_switch)
}
fn secrets_action(&self) -> FilterAction {
if self.secret_patterns.enabled {
self.gateway.secrets
} else {
FilterAction::Off
}
}
fn actions(&self) -> detectors::Actions {
detectors::Actions {
secrets: self.secrets_action(),
pii: self.gateway.pii,
injection: self.gateway.injection,
classification: self.gateway.classification,
}
}
fn budget(&self) -> detectors::Budget {
detectors::Budget {
max_bytes: self.gateway.max_inspected_bytes,
deadline: std::time::Duration::from_millis(self.gateway.detector_timeout_ms),
}
}
fn fingerprint(&self) -> Vec<u8> {
let mut value = serde_json::json!({
"gateway": &self.gateway,
"secret_patterns": &self.secret_patterns,
"semantic": self
.semantic
.as_ref()
.map_or(serde_json::Value::Bool(false), semantic::SemanticDetector::identity),
});
if let Some(failure) = self.load_failure
&& let Some(map) = value.as_object_mut()
{
map.insert("config_unloadable".to_owned(), failure.code().into());
}
let effective = self.enabled_effective();
if effective != self.gateway.enabled
&& let Some(map) = value.as_object_mut()
{
map.insert("enabled_effective".to_owned(), effective.into());
}
serde_json::to_vec(&value).unwrap_or_default()
}
fn detectors_mandatory(&self) -> bool {
self.gateway.mode != GatewayModeV1::Developer
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AdmissionCounts {
pub secrets: u32,
pub pii: u32,
pub injection: u32,
pub injection_patterns: u8,
pub markings: u32,
pub incomplete: u32,
pub semantic: u32,
}
#[derive(Debug, Clone)]
pub struct Admission {
pub text: Option<String>,
pub classification: ClassificationV1,
pub trust: TrustLevelV1,
pub decision: ContextDecisionV1,
pub counts: AdmissionCounts,
}
impl Admission {
#[must_use]
pub fn disposition(&self) -> ContextDispositionV1 {
self.decision.disposition
}
#[must_use]
pub fn storable(&self) -> bool {
self.text.is_some() && self.classification < ClassificationV1::Restricted
}
#[must_use]
pub fn hud_line(&self) -> Option<String> {
self.hud_line_placed(true)
}
#[must_use]
pub fn hud_line_placed(&self, redaction_counts: bool) -> Option<String> {
render_hud(
self.counts,
&self.decision,
self.text.is_none(),
redaction_counts,
)
}
}
fn render_hud(
c: AdmissionCounts,
decision: &ContextDecisionV1,
withheld: bool,
redaction_counts: bool,
) -> Option<String> {
let mut parts = Vec::new();
let redacted = decision.disposition == ContextDispositionV1::AllowRedacted;
let verb = if redacted { "redacted" } else { "detected" };
let show_values = !redacted || redaction_counts;
if c.secrets > 0 && show_values {
parts.push(format!("{} secret(s) {verb}", c.secrets));
}
if c.pii > 0 && show_values {
parts.push(format!("{} PII value(s) {verb}", c.pii));
}
if c.injection > 0 {
parts.push(format!("{} prompt-injection signal(s)", c.injection));
}
if c.markings > 0 {
parts.push(format!("{} classification marking(s)", c.markings));
}
let codes = || decision.reason_codes.iter().map(ReasonCodeV1::as_str);
if c.incomplete > 0 {
let reasons: Vec<&str> = codes()
.filter(|code| code.starts_with("coverage.") || code.starts_with("detector."))
.collect();
parts.push(format!("not fully inspected ({})", reasons.join(", ")));
}
if c.semantic > 0 {
parts.push(format!("{} semantic finding(s)", c.semantic));
}
let semantic_gaps: Vec<&str> = codes()
.filter(|code| {
matches!(*code, "semantic.partial" | "semantic.unavailable")
|| (code.starts_with("semantic.") && code.ends_with(".not_inspected"))
})
.collect();
if !semantic_gaps.is_empty() {
parts.push(format!(
"semantic check incomplete ({})",
semantic_gaps.join(", ")
));
}
if withheld {
let reasons: Vec<&str> = codes().collect();
return Some(format!(
"[lean-ctx gateway: content withheld — {}]",
reasons.join(", ")
));
}
(!parts.is_empty()).then(|| format!("[lean-ctx gateway: {}]", parts.join(" · ")))
}
#[must_use]
pub fn admit(
text: &str,
path: Option<&Path>,
class: &origin::SourceClass,
policy: &AdmissionPolicy,
) -> Admission {
capture::note_origin(class);
let floor = class.origin_floor();
let object = content_digest(text);
let gateway = &policy.gateway;
if let Some(codes) = policy.config_refusal_codes() {
return Admission {
text: None,
classification: gateway.mode.unclassified(),
trust: TrustLevelV1::Unknown,
decision: ContextDecisionV1 {
object,
disposition: ContextDispositionV1::Deny,
reason_codes: codes.iter().map(|code| reason(code)).collect(),
signals: Vec::new(),
required_transformations: Vec::new(),
},
counts: AdmissionCounts::default(),
};
}
if !policy.gateway_switch() {
return Admission {
text: Some(text.to_owned()),
classification: gateway.mode.unclassified(),
trust: floor,
decision: ContextDecisionV1 {
object,
disposition: ContextDispositionV1::Allow,
reason_codes: vec![reason("gateway.disabled")],
signals: Vec::new(),
required_transformations: Vec::new(),
},
counts: AdmissionCounts::default(),
};
}
let mut actions = policy.actions();
if class.is_external_or_unknown()
&& !capture::egress_check_active()
&& actions.injection == FilterAction::Warn
{
actions.injection = FilterAction::Redact;
}
let findings = detectors::inspect(
text,
path,
&actions,
&policy.secret_patterns,
policy.budget(),
);
let mut counts = findings.counts();
let mut reasons = Vec::new();
let mut disposition = ContextDispositionV1::Allow;
for incomplete in findings.incomplete() {
reasons.push(incomplete);
if policy.detectors_mandatory() {
disposition = ContextDispositionV1::Deny;
}
}
let mut transformations = Vec::new();
for (action, hits, code) in [
(actions.secrets, counts.secrets, "secret"),
(actions.pii, counts.pii, "pii"),
(actions.injection, counts.injection, "injection"),
(actions.classification, counts.markings, "classification"),
] {
if hits == 0 {
continue;
}
let step = match action {
FilterAction::Block => {
reasons.push(reason(&format!("{code}.blocked")));
ContextDispositionV1::Deny
}
FilterAction::Redact if code == "classification" => {
reasons.push(reason(&format!("{code}.blocked")));
ContextDispositionV1::Deny
}
FilterAction::Redact => {
reasons.push(reason(&format!("{code}.redacted")));
if !transformations.contains(&TransformationKindV1::Redaction) {
transformations.push(TransformationKindV1::Redaction);
}
ContextDispositionV1::AllowRedacted
}
FilterAction::Warn => {
reasons.push(reason(&format!("{code}.flagged")));
ContextDispositionV1::Allow
}
FilterAction::Off => continue,
};
disposition = disposition.most_restrictive(step);
}
let semantic = policy
.semantic
.as_ref()
.map(|detect| semantic::run(text, detect, &actions, gateway.max_inspected_bytes));
if let Some(outcome) = &semantic {
counts.semantic = outcome.hits;
for (step, code) in &outcome.steps {
reasons.push(code.clone());
if *step == ContextDispositionV1::AllowRedacted
&& !transformations.contains(&TransformationKindV1::Redaction)
{
transformations.push(TransformationKindV1::Redaction);
}
disposition = disposition.most_restrictive(*step);
}
if let Some(code) = &outcome.incomplete {
reasons.push(code.clone());
}
}
let mut delivered = if disposition == ContextDispositionV1::Deny {
None
} else {
let semantic_masked = semantic
.as_ref()
.filter(|outcome| !outcome.redactions.is_empty())
.map(|outcome| semantic::redact(text, &outcome.redactions));
let source = semantic_masked.as_deref().unwrap_or(text);
Some(findings.redact(source, &actions, &policy.secret_patterns))
};
if let Some(output) = &delivered
&& output != text
&& detectors::residual(output, &actions, &policy.secret_patterns)
{
reasons.push(reason("redaction.incomplete"));
disposition = ContextDispositionV1::Deny;
delivered = None;
}
let classification = findings.classification(gateway.mode, &actions);
let semantic_injection = semantic.as_ref().is_some_and(|outcome| {
outcome
.steps
.iter()
.any(|(_, code)| {
let code = code.as_str();
code.starts_with("semantic.prompt_injection") && !code.ends_with(".not_inspected")
})
});
let content_trust = if counts.injection > 0 || semantic_injection {
TrustLevelV1::Untrusted
} else {
TrustLevelV1::Internal
};
let trust = floor.min(content_trust);
let mut signals: Vec<DetectorSignalV1> = findings.signals();
if let Some(outcome) = semantic {
signals.push(outcome.signal);
}
Admission {
text: delivered,
classification,
trust,
decision: ContextDecisionV1 {
object,
disposition,
reason_codes: reasons,
signals,
required_transformations: transformations,
},
counts,
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct OutputCompleteness {
pub truncated: bool,
pub timed_out: bool,
pub reader_incomplete: bool,
pub cancelled: bool,
pub decode_lossy: bool,
}
impl OutputCompleteness {
pub const fn complete() -> Self {
Self {
truncated: false,
timed_out: false,
reader_incomplete: false,
cancelled: false,
decode_lossy: false,
}
}
pub fn is_complete(self) -> bool {
!(self.truncated
|| self.timed_out
|| self.reader_incomplete
|| self.cancelled
|| self.decode_lossy)
}
fn reason_codes(self) -> Vec<&'static str> {
let mut reasons = Vec::new();
if self.truncated {
reasons.push("coverage.output_truncated");
}
if self.timed_out {
reasons.push("coverage.output_timed_out");
}
if self.reader_incomplete {
reasons.push("coverage.output_reader_incomplete");
}
if self.cancelled {
reasons.push("coverage.output_cancelled");
}
if self.decode_lossy {
reasons.push("coverage.output_decode_lossy");
}
reasons
}
}
#[derive(Debug, Clone)]
pub struct ToolOutputAdmission {
pub text: Option<String>,
pub notice: Option<String>,
}
impl ToolOutputAdmission {
pub fn withheld(&self) -> bool {
self.text.is_none()
}
pub fn rendered(&self) -> String {
match (&self.text, &self.notice) {
(Some(text), Some(notice)) if text.is_empty() => notice.clone(),
(Some(text), Some(notice)) => format!("{notice}\n{text}"),
(Some(text), None) => text.clone(),
(None, Some(notice)) => notice.clone(),
(None, None) => "[lean-ctx gateway: content withheld]".to_string(),
}
}
}
pub fn admit_tool_output(text: &str, completeness: OutputCompleteness) -> ToolOutputAdmission {
let class = capture::current()
.and_then(|capture| capture.output_class())
.unwrap_or(origin::SourceClass::Unclassified {
tool: "tool_output",
});
admit_tool_output_as(text, completeness, &class)
}
pub fn admit_tool_output_as(
text: &str,
completeness: OutputCompleteness,
class: &origin::SourceClass,
) -> ToolOutputAdmission {
capture::note_origin(class);
let project_original = crate::core::policy::content::protect_active(text);
let project_rewrote = project_original
.as_ref()
.is_ok_and(|admitted| admitted.as_ref() != text);
let policy = carrier::current_policy();
let mut admission = admit(text, None, class, &policy);
mark_capture_incomplete(&mut admission, completeness, &policy);
let gateway_output = admission.text.clone();
let text = project_original
.as_deref()
.ok()
.zip(gateway_output.as_deref())
.and_then(|(project_original, gateway_output)| {
compose_active_gateway_output(
project_original,
gateway_output,
class,
&policy,
completeness,
)
});
let policy_withheld = project_original.is_err() || (gateway_output.is_some() && text.is_none());
if let Some(admitted) = &text {
admission.text = Some(admitted.clone());
if project_rewrote
|| gateway_output
.as_deref()
.is_some_and(|gateway| gateway != admitted)
{
mark_project_redaction(&mut admission);
}
} else if policy_withheld {
mark_policy_withheld(&mut admission, "policy.content_withheld");
}
let capture = capture::current();
if let Some(capture) = &capture {
capture.bind_policy(
policy.gateway.mode,
fingerprint_digest(&policy.fingerprint()),
);
capture.record_as(
&admission.decision,
admission.counts,
class.is_external_or_unknown(),
);
}
let mut notice = admission.hud_line_placed(policy.gateway.hud.redaction_counts_in_band());
if policy_withheld {
notice.get_or_insert_with(|| "[lean-ctx policy: content withheld]".to_string());
}
ToolOutputAdmission { text, notice }
}
fn mark_project_redaction(admission: &mut Admission) {
admission.decision.disposition = admission
.decision
.disposition
.most_restrictive(ContextDispositionV1::AllowRedacted);
let reason = reason("policy.content_redacted");
if !admission.decision.reason_codes.contains(&reason) {
admission.decision.reason_codes.push(reason);
}
if !admission
.decision
.required_transformations
.contains(&TransformationKindV1::Redaction)
{
admission
.decision
.required_transformations
.push(TransformationKindV1::Redaction);
}
}
fn mark_policy_withheld(admission: &mut Admission, reason_code: &str) {
admission.text = None;
admission.decision.disposition = ContextDispositionV1::Deny;
let reason = reason(reason_code);
if !admission.decision.reason_codes.contains(&reason) {
admission.decision.reason_codes.push(reason);
}
}
pub(crate) fn compose_active_gateway_output(
project_original: &str,
gateway_output: &str,
class: &origin::SourceClass,
policy: &AdmissionPolicy,
completeness: OutputCompleteness,
) -> Option<String> {
let project_after_gateway = crate::core::policy::content::protect_active(gateway_output)
.ok()?
.into_owned();
let mut gateway_after_project = admit(project_original, None, class, policy);
mark_capture_incomplete(&mut gateway_after_project, completeness, policy);
let gateway_after_project = gateway_after_project.text?;
(project_after_gateway == gateway_after_project).then_some(project_after_gateway)
}
pub(super) fn admit_output_for_storage(
text: &str,
completeness: OutputCompleteness,
policy: &AdmissionPolicy,
) -> Option<String> {
if !completeness.is_complete() {
return None;
}
let project_original = crate::core::policy::content::protect_active(text).ok()?;
let mut admission = admit(text, None, &origin::SourceClass::Project, policy);
mark_capture_incomplete(&mut admission, completeness, policy);
if !admission.storable() {
return None;
}
let gateway_output = admission.text?;
compose_active_gateway_output(
project_original.as_ref(),
&gateway_output,
&origin::SourceClass::Project,
policy,
completeness,
)
}
pub(super) fn admit_output_for_poll(
text: &str,
completeness: OutputCompleteness,
policy: &AdmissionPolicy,
) -> Option<String> {
let without_reader = OutputCompleteness {
reader_incomplete: false,
..completeness
};
if !completeness.reader_incomplete {
return admit_output_for_storage(text, completeness, policy);
}
if !without_reader.is_complete() || (policy.enabled_effective() && policy.detectors_mandatory())
{
return None;
}
admit_output_for_storage(text, without_reader, policy)
}
fn mark_capture_incomplete(
admission: &mut Admission,
completeness: OutputCompleteness,
policy: &AdmissionPolicy,
) {
if completeness.is_complete() || !policy.enabled_effective() {
return;
}
for code in completeness.reason_codes() {
let reason = reason(code);
if !admission.decision.reason_codes.contains(&reason) {
admission.decision.reason_codes.push(reason);
}
}
let partial_reason = reason("coverage.output_partial");
if !admission.decision.reason_codes.contains(&partial_reason) {
admission.decision.reason_codes.push(partial_reason.clone());
}
for signal in &mut admission.decision.signals {
if signal.coverage.kind != CoverageKindV1::Complete {
continue;
}
signal.coverage.kind = CoverageKindV1::Partial;
signal.coverage.bytes_total = signal.coverage.bytes_inspected.saturating_add(1);
signal.coverage.chunks_total = signal.coverage.chunks_inspected.saturating_add(1);
signal.coverage.reason = Some(partial_reason.clone());
}
admission.counts.incomplete = admission.counts.incomplete.max(1);
if policy.detectors_mandatory() {
admission.decision.disposition = ContextDispositionV1::Deny;
admission.text = None;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceAdmission {
pub text: String,
pub storable: bool,
pub(crate) classification: ClassificationV1,
}
pub fn admit_source(
text: &str,
path: &str,
preserve_bytes: bool,
class: &origin::SourceClass,
) -> Result<String, std::io::Error> {
admit_source_with_storage(
text,
path,
preserve_bytes,
OutputCompleteness::complete(),
class,
)
.map(|admission| admission.text)
}
pub fn admit_instance_source(
text: &str,
label: &str,
preserve_bytes: bool,
) -> Result<String, std::io::Error> {
admit_source(text, label, preserve_bytes, &origin::SourceClass::Project)
}
pub fn admit_instance_context_source(text: &str) -> Result<SourceAdmission, std::io::Error> {
admit_context_source(text, &origin::SourceClass::Project)
}
pub(crate) fn admit_project_file_with_storage(
text: &str,
path: &str,
preserve_bytes: bool,
completeness: OutputCompleteness,
) -> Result<SourceAdmission, std::io::Error> {
admit_source_with_storage(
text,
path,
preserve_bytes,
completeness,
&origin::SourceClass::Project,
)
}
pub(crate) fn admit_source_with_storage(
text: &str,
path: &str,
preserve_bytes: bool,
completeness: OutputCompleteness,
class: &origin::SourceClass,
) -> Result<SourceAdmission, std::io::Error> {
admit_source_inner(text, Some(path), preserve_bytes, completeness, None, class)
}
pub fn admit_context_source(
text: &str,
class: &origin::SourceClass,
) -> Result<SourceAdmission, std::io::Error> {
admit_source_inner(
text,
None,
false,
OutputCompleteness::complete(),
None,
class,
)
}
pub(crate) fn admit_bound_context_source(
text: &str,
class: &origin::SourceClass,
) -> Result<SourceAdmission, std::io::Error> {
admit_source_inner(
text,
None,
true,
OutputCompleteness::complete(),
None,
class,
)
}
pub(crate) fn admit_source_with_snapshot(
text: &str,
path: &str,
preserve_bytes: bool,
completeness: OutputCompleteness,
snapshot: &stores::StoreAdmission,
) -> Result<SourceAdmission, std::io::Error> {
admit_source_inner(
text,
Some(path),
preserve_bytes,
completeness,
Some(snapshot.admission_policy()),
&origin::SourceClass::Project,
)
}
fn admit_source_inner(
text: &str,
path: Option<&str>,
preserve_bytes: bool,
completeness: OutputCompleteness,
policy_snapshot: Option<&AdmissionPolicy>,
class: &origin::SourceClass,
) -> Result<SourceAdmission, std::io::Error> {
capture::note_origin(class);
admit_source_typed(
text,
path,
preserve_bytes,
completeness,
policy_snapshot,
class,
)
.map_err(|reasons| {
let hint = withheld_remedy(&reasons);
let reasons: Vec<&str> = reasons.iter().map(ReasonCodeV1::as_str).collect();
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!(
"source content withheld by the context gateway ({}){hint}",
reasons.join(", ")
),
)
})
}
fn withheld_remedy(reasons: &[ReasonCodeV1]) -> &'static str {
let has = |code: &str| reasons.iter().any(|reason| reason.as_str() == code);
if has("gateway.config_unloadable") {
" — the global config.toml could not be loaded; fix it (lean-ctx doctor) to restore reads"
} else if has("coverage.partial") || has("coverage.budget_exhausted") {
" — the object exceeds [context_gateway] max_inspected_bytes and governed/sovereign mode \
withholds what detectors cannot fully inspect"
} else if has("detector.timed_out") {
" — a detector exceeded [context_gateway] detector_timeout_ms in governed/sovereign mode"
} else if has("classification.blocked") {
" — a classification marking matched [context_gateway] classification = redact|block"
} else if has("secret.blocked") {
" — a secret matched [context_gateway] secrets = \"block\""
} else if has("injection.blocked") {
" — prompt-injection text matched [context_gateway] injection = \"block\""
} else if has("redaction.incomplete") {
" — a redaction left a detectable value behind, so nothing is delivered"
} else if has("policy.content_withheld") {
" — withheld by the project or organization policy"
} else {
""
}
}
fn admit_source_typed(
text: &str,
path: Option<&str>,
preserve_bytes: bool,
completeness: OutputCompleteness,
policy_snapshot: Option<&AdmissionPolicy>,
class: &origin::SourceClass,
) -> Result<SourceAdmission, Vec<ReasonCodeV1>> {
let external = class.is_external_or_unknown();
let project_original = crate::core::policy::content::protect_active(text);
let project_rewrote = project_original
.as_ref()
.is_ok_and(|admitted| admitted.as_ref() != text);
let policy = policy_snapshot
.cloned()
.unwrap_or_else(carrier::current_policy);
let fingerprint = policy.fingerprint();
let capture = capture::current();
if let Some(capture) = &capture {
capture.bind_policy(policy.gateway.mode, fingerprint_digest(&fingerprint));
}
let memo_key = path.and_then(|path| {
(completeness.is_complete()
&& project_original.is_ok()
&& !project_rewrote
&& policy.semantic.is_none()
&& !policy.config_unloadable()
&& policy.gateway_switch())
.then(|| clean::key(&fingerprint, path, text))
});
if let Some(key) = &memo_key
&& let Some(decision) = clean::get_source(key)
{
if let Some(capture) = &capture {
capture.record_as(&decision.decision, AdmissionCounts::default(), external);
}
return Ok(SourceAdmission {
text: project_original
.expect("memo key requires accepted project policy")
.into_owned(),
storable: decision.storable,
classification: decision.classification,
});
}
let mut admission = admit(text, path.map(Path::new), class, &policy);
mark_capture_incomplete(&mut admission, completeness, &policy);
let storable = completeness.is_complete() && admission.storable();
let gateway_admitted = admission.text.clone();
let mut project_admitted = project_original
.as_deref()
.ok()
.zip(gateway_admitted.as_deref())
.and_then(|(project_original, gateway_admitted)| {
compose_active_gateway_output(
project_original,
gateway_admitted,
class,
&policy,
completeness,
)
});
let mut refusal_reason = if project_original.is_err()
|| (gateway_admitted.is_some() && project_admitted.is_none())
{
Some("policy.content_withheld")
} else {
None
};
if let Some(admitted) = &project_admitted {
if project_rewrote
|| gateway_admitted
.as_deref()
.is_some_and(|gateway| gateway != admitted)
{
admission.text = Some(admitted.clone());
mark_project_redaction(&mut admission);
}
}
if preserve_bytes
&& let Some(gateway_admitted) = gateway_admitted.as_deref()
&& let Some(project_admitted_text) = project_admitted.as_deref()
&& preserve_bytes_rewrite_reason(
text,
gateway_admitted,
project_admitted_text,
project_rewrote,
)
.is_some()
{
project_admitted = None;
refusal_reason = Some("source.bytes_not_preserved");
}
if let Some(reason_code) = refusal_reason {
mark_policy_withheld(&mut admission, reason_code);
}
if let Some(capture) = &capture {
capture.record_as(&admission.decision, admission.counts, external);
}
let Some(project_admitted) = project_admitted else {
return Err(admission.decision.reason_codes);
};
if let Some(key) = memo_key
&& admission.disposition() == ContextDispositionV1::Allow
&& admission.decision.reason_codes.is_empty()
&& admission.text.as_deref() == Some(text)
&& project_admitted == text
{
clean::insert_source(
key,
admission.decision.clone(),
admission.classification,
admission.storable(),
);
}
let redaction_counts = policy.gateway.hud.redaction_counts_in_band();
if admission.hud_line_placed(redaction_counts).is_some() {
notes::record(
project_admitted.as_ref(),
notes::Note {
counts: admission.counts,
decision: admission.decision.clone(),
redaction_counts,
},
);
}
Ok(SourceAdmission {
text: project_admitted,
storable,
classification: admission.classification,
})
}
fn preserve_bytes_rewrite_reason(
source: &str,
gateway_admitted: &str,
project_admitted: &str,
project_rewrote_original: bool,
) -> Option<&'static str> {
if project_rewrote_original || project_admitted != gateway_admitted {
Some("source content withheld: an active policy would rewrite a digest-bound source")
} else if gateway_admitted != source {
Some("source content withheld: the context gateway would rewrite a digest-bound source")
} else {
None
}
}
#[must_use]
pub fn admit_uninspectable(path: Option<&Path>, bytes: u64, policy: &AdmissionPolicy) -> Admission {
let gateway = &policy.gateway;
let object = content_digest(&format!("uninspectable:{bytes}"));
let actions = policy.actions();
let signals = detectors::unsupported_signals(&actions, bytes);
let required = signals.iter().any(|signal| {
signal.coverage.kind == lean_ctx_protocol::context_gateway::CoverageKindV1::Unsupported
});
if policy.config_unloadable() || !policy.gateway_switch() || !required {
return admit("", path, &origin::SourceClass::Project, policy);
}
let restricted_path =
path.is_some_and(|p| crate::core::io_boundary::is_secret_like(p).is_some());
let mandatory = policy.detectors_mandatory();
Admission {
text: (!mandatory).then(String::new),
classification: if restricted_path {
ClassificationV1::Restricted
} else {
gateway.mode.unclassified()
},
trust: TrustLevelV1::Unknown,
decision: ContextDecisionV1 {
object,
disposition: if mandatory {
ContextDispositionV1::Deny
} else {
ContextDispositionV1::Allow
},
reason_codes: vec![reason("coverage.unsupported_media")],
signals,
required_transformations: Vec::new(),
},
counts: AdmissionCounts {
incomplete: 1,
..AdmissionCounts::default()
},
}
}
pub fn admit_media(path: &str, bytes: u64) -> Result<Option<String>, std::io::Error> {
let policy = carrier::current_policy();
let admission = admit_uninspectable(Some(Path::new(path)), bytes, &policy);
if let Some(capture) = capture::current() {
capture.bind_policy(
policy.gateway.mode,
fingerprint_digest(&policy.fingerprint()),
);
capture.record(&admission.decision, admission.counts);
}
if admission.text.is_none() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"media withheld by the context gateway (coverage.unsupported_media)",
));
}
Ok((admission.counts.incomplete > 0)
.then(|| "[lean-ctx gateway: media not inspected (coverage.unsupported_media)]".to_owned()))
}
#[cfg(any(test, feature = "test-support"))]
pub fn clear_clean_memo() {
clean::clear();
}
#[must_use]
pub fn note_for(admitted: &str, delivered: &str) -> Option<String> {
let note = notes::lookup(admitted)?;
let mut counts = note.counts;
if note.decision.disposition == ContextDispositionV1::AllowRedacted {
let pii = crate::core::input_filters::pii::checksummed_marker_count(delivered);
let other = delivered
.matches("[REDACTED:")
.count()
.saturating_sub(pii + delivered.matches(INJECTION_MARKER).count());
counts.pii = counts.pii.min(saturating_u32(pii));
counts.secrets = counts.secrets.min(saturating_u32(other));
}
render_hud(counts, ¬e.decision, false, note.redaction_counts)
}
const INJECTION_MARKER: &str = "[REDACTED:prompt-injection]";
fn saturating_u32(n: usize) -> u32 {
u32::try_from(n).unwrap_or(u32::MAX)
}
fn reason(code: &str) -> ReasonCodeV1 {
ReasonCodeV1::new(code).expect("built-in reason codes are valid")
}
pub(crate) fn record_recheck_refusal(
admission: &stores::StoreAdmission,
validity: lean_ctx_protocol::context_lineage::SourceValidityV1,
) {
let Some(capture) = capture::current() else {
return;
};
let policy = admission.admission_policy();
capture.bind_policy(
policy.gateway.mode,
fingerprint_digest(&policy.fingerprint()),
);
let decision = ContextDecisionV1 {
object: fingerprint_digest(b"source-recheck-refused"),
disposition: ContextDispositionV1::Deny,
reason_codes: vec![reason(validity.reason_code())],
signals: Vec::new(),
required_transformations: Vec::new(),
};
capture.record(&decision, AdmissionCounts::default());
}
fn fingerprint_digest(fingerprint: &[u8]) -> Sha256Digest {
let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(fingerprint));
Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
}
fn content_digest(text: &str) -> Sha256Digest {
let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(text.as_bytes()));
Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
}
#[cfg(test)]
mod shell_admission_regression_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod receipt_tests;
#[cfg(test)]
mod store_tests;
#[cfg(test)]
mod egress_tests;
#[cfg(test)]
mod egress_api_tests;