1pub mod capture;
19pub mod carrier;
20mod clean;
21pub(crate) mod cross_line;
22mod detectors;
23pub mod egress;
24pub mod hud;
25mod notes;
26pub mod origin;
27pub mod provider;
28pub mod receipt_store;
29pub mod recovery;
30pub(crate) mod semantic;
31pub mod semantic_deferred;
32pub mod stored;
33pub mod stores;
34pub mod withheld;
35
36use std::path::Path;
37
38use lean_ctx_protocol::Sha256Digest;
39use lean_ctx_protocol::context_gateway::{
40 ClassificationV1, ContextDecisionV1, ContextDispositionV1, CoverageKindV1,
41 DestinationConstraintV1, DestinationLocalityV1, DestinationV1, DetectorSignalV1, GatewayModeV1,
42 ReasonCodeV1, TransformationKindV1, TrustLevelV1,
43};
44use serde::{Deserialize, Serialize};
45use sha2::{Digest, Sha256};
46
47use crate::core::config::SecretDetectionConfig;
48use crate::core::input_filters::FilterAction;
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(default)]
53pub struct ContextGatewayConfig {
54 pub enabled: bool,
56 pub mode: GatewayModeV1,
57 pub secrets: FilterAction,
59 pub pii: FilterAction,
61 pub injection: FilterAction,
63 pub classification: FilterAction,
65 pub max_inspected_bytes: usize,
69 pub detector_timeout_ms: u64,
71 pub destination_constraints: Vec<DestinationConstraintV1>,
74 pub allowed_destinations: Vec<DestinationV1>,
79 pub hud: hud::HudPlacement,
81}
82
83pub const DEFAULT_MAX_INSPECTED_BYTES: usize = 8 * 1024 * 1024;
85pub const DEFAULT_DETECTOR_TIMEOUT_MS: u64 = 2_000;
86
87impl Default for ContextGatewayConfig {
88 fn default() -> Self {
89 Self {
90 enabled: true,
91 mode: GatewayModeV1::Developer,
92 secrets: FilterAction::Redact,
93 pii: FilterAction::Redact,
94 injection: FilterAction::Warn,
95 classification: FilterAction::Warn,
96 max_inspected_bytes: DEFAULT_MAX_INSPECTED_BYTES,
97 detector_timeout_ms: DEFAULT_DETECTOR_TIMEOUT_MS,
98 destination_constraints: Vec::new(),
99 allowed_destinations: Vec::new(),
100 hud: hud::HudPlacement::Auto,
101 }
102 }
103}
104
105impl ContextGatewayConfig {
106 #[must_use]
113 pub fn enabled_effective(&self) -> bool {
114 self.enabled_under(EnvSwitch::read())
115 }
116
117 #[must_use]
121 pub(crate) fn enabled_under(&self, env: EnvSwitch) -> bool {
122 match self.kill_switch_under(env) {
123 KillSwitch::Unset | KillSwitch::Ignored => self.enabled,
124 KillSwitch::ForcedOn => true,
125 KillSwitch::Honoured => false,
126 }
127 }
128
129 #[must_use]
133 pub fn kill_switch(&self) -> KillSwitch {
134 self.kill_switch_under(EnvSwitch::read())
135 }
136
137 fn kill_switch_under(&self, env: EnvSwitch) -> KillSwitch {
138 match env {
139 EnvSwitch::Unset => return KillSwitch::Unset,
140 EnvSwitch::On => return KillSwitch::ForcedOn,
141 EnvSwitch::Off => {}
142 }
143 if self.mode == GatewayModeV1::Developer {
144 KillSwitch::Honoured
145 } else {
146 static REPORTED: std::sync::Once = std::sync::Once::new();
147 REPORTED.call_once(|| {
148 tracing::warn!(
149 "LEAN_CTX_CONTEXT_GATEWAY=off ignored: the context gateway runs in {:?} mode",
150 self.mode
151 );
152 });
153 KillSwitch::Ignored
154 }
155 }
156
157 #[must_use]
160 pub(crate) fn has_destination_policy(&self) -> bool {
161 !self.destination_constraints.is_empty() || !self.allowed_destinations.is_empty()
162 }
163
164 #[must_use]
166 pub(crate) fn allows_destination(&self, actual: &DestinationV1) -> bool {
167 if actual.locality == DestinationLocalityV1::Unknown
168 || !actual.satisfies_all(&self.destination_constraints)
169 {
170 return false;
171 }
172 self.allowed_destinations.is_empty()
173 || self.allowed_destinations.iter().any(|expected| {
174 expected.locality != DestinationLocalityV1::Unknown
175 && expected.provider == actual.provider
176 && expected.locality == actual.locality
177 && expected.organization_managed == actual.organization_managed
178 && expected
179 .model
180 .as_ref()
181 .is_none_or(|reference| actual.model.as_ref() == Some(reference))
182 && expected
183 .account_ref
184 .as_ref()
185 .is_none_or(|reference| actual.account_ref.as_ref() == Some(reference))
186 && expected
187 .region
188 .as_ref()
189 .is_none_or(|reference| actual.region.as_ref() == Some(reference))
190 })
191 }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub(crate) enum EnvSwitch {
198 Unset,
199 Off,
201 On,
203}
204
205impl EnvSwitch {
206 pub(crate) fn read() -> Self {
208 match std::env::var("LEAN_CTX_CONTEXT_GATEWAY") {
209 Err(_) => Self::Unset,
210 Ok(value) if matches!(value.trim(), "0" | "false" | "off") => Self::Off,
211 Ok(_) => Self::On,
212 }
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum KillSwitch {
219 Unset,
220 ForcedOn,
222 Honoured,
224 Ignored,
226}
227
228#[derive(Debug, Clone)]
230pub struct AdmissionPolicy {
231 pub gateway: ContextGatewayConfig,
232 pub secret_patterns: SecretDetectionConfig,
235 pub(crate) semantic: Option<semantic::SemanticDetector>,
237 load_failure: Option<crate::core::config::ConfigLoadFailure>,
242 env_switch: EnvSwitch,
244}
245
246impl AdmissionPolicy {
247 #[must_use]
251 pub fn from_config(config: &crate::core::config::Config) -> Self {
252 Self {
253 gateway: config.context_gateway.clone(),
254 secret_patterns: config.secret_detection.clone(),
255 semantic: semantic::SemanticDetector::for_snapshot(config),
256 load_failure: config.load_failure,
257 env_switch: EnvSwitch::read(),
258 }
259 }
260
261 #[must_use]
263 pub fn builtin(gateway: ContextGatewayConfig, secret_patterns: SecretDetectionConfig) -> Self {
264 Self {
265 gateway,
266 secret_patterns,
267 semantic: None,
268 load_failure: None,
269 env_switch: EnvSwitch::read(),
270 }
271 }
272
273 #[must_use]
276 pub fn config_unloadable(&self) -> bool {
277 self.load_failure.is_some()
278 }
279
280 pub(crate) fn config_refusal_codes(&self) -> Option<[&'static str; 2]> {
284 self.load_failure
285 .map(|failure| ["gateway.config_unloadable", failure.code()])
286 }
287
288 #[must_use]
292 pub fn enabled_effective(&self) -> bool {
293 self.config_unloadable() || self.gateway_switch()
294 }
295
296 fn gateway_switch(&self) -> bool {
302 self.gateway.enabled_under(self.env_switch)
303 }
304
305 fn secrets_action(&self) -> FilterAction {
306 if self.secret_patterns.enabled {
307 self.gateway.secrets
308 } else {
309 FilterAction::Off
310 }
311 }
312
313 fn actions(&self) -> detectors::Actions {
314 detectors::Actions {
315 secrets: self.secrets_action(),
316 pii: self.gateway.pii,
317 injection: self.gateway.injection,
318 classification: self.gateway.classification,
319 }
320 }
321
322 fn budget(&self) -> detectors::Budget {
323 detectors::Budget {
324 max_bytes: self.gateway.max_inspected_bytes,
325 deadline: std::time::Duration::from_millis(self.gateway.detector_timeout_ms),
326 }
327 }
328
329 fn fingerprint(&self) -> Vec<u8> {
333 let mut value = serde_json::json!({
334 "gateway": &self.gateway,
335 "secret_patterns": &self.secret_patterns,
336 "semantic": self
339 .semantic
340 .as_ref()
341 .map_or(serde_json::Value::Bool(false), semantic::SemanticDetector::identity),
342 });
343 if let Some(failure) = self.load_failure
346 && let Some(map) = value.as_object_mut()
347 {
348 map.insert("config_unloadable".to_owned(), failure.code().into());
349 }
350 let effective = self.enabled_effective();
356 if effective != self.gateway.enabled
357 && let Some(map) = value.as_object_mut()
358 {
359 map.insert("enabled_effective".to_owned(), effective.into());
360 }
361 serde_json::to_vec(&value).unwrap_or_default()
362 }
363
364 fn detectors_mandatory(&self) -> bool {
367 self.gateway.mode != GatewayModeV1::Developer
368 }
369}
370
371#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
373pub struct AdmissionCounts {
374 pub secrets: u32,
375 pub pii: u32,
376 pub injection: u32,
379 pub injection_patterns: u8,
382 pub markings: u32,
383 pub incomplete: u32,
385 pub semantic: u32,
387}
388
389#[derive(Debug, Clone)]
391pub struct Admission {
392 pub text: Option<String>,
394 pub classification: ClassificationV1,
395 pub trust: TrustLevelV1,
396 pub decision: ContextDecisionV1,
397 pub counts: AdmissionCounts,
398}
399
400impl Admission {
401 #[must_use]
402 pub fn disposition(&self) -> ContextDispositionV1 {
403 self.decision.disposition
404 }
405
406 #[must_use]
409 pub fn storable(&self) -> bool {
410 self.text.is_some() && self.classification < ClassificationV1::Restricted
411 }
412
413 #[must_use]
416 pub fn hud_line(&self) -> Option<String> {
417 self.hud_line_placed(true)
418 }
419
420 #[must_use]
427 pub fn hud_line_placed(&self, redaction_counts: bool) -> Option<String> {
428 render_hud(
429 self.counts,
430 &self.decision,
431 self.text.is_none(),
432 redaction_counts,
433 )
434 }
435}
436
437fn render_hud(
439 c: AdmissionCounts,
440 decision: &ContextDecisionV1,
441 withheld: bool,
442 redaction_counts: bool,
443) -> Option<String> {
444 let mut parts = Vec::new();
445 let redacted = decision.disposition == ContextDispositionV1::AllowRedacted;
446 let verb = if redacted { "redacted" } else { "detected" };
447 let show_values = !redacted || redaction_counts;
448 if c.secrets > 0 && show_values {
449 parts.push(format!("{} secret(s) {verb}", c.secrets));
450 }
451 if c.pii > 0 && show_values {
452 parts.push(format!("{} PII value(s) {verb}", c.pii));
453 }
454 if c.injection > 0 {
455 parts.push(format!("{} prompt-injection signal(s)", c.injection));
456 }
457 if c.markings > 0 {
458 parts.push(format!("{} classification marking(s)", c.markings));
459 }
460 let codes = || decision.reason_codes.iter().map(ReasonCodeV1::as_str);
461 if c.incomplete > 0 {
462 let reasons: Vec<&str> = codes()
463 .filter(|code| code.starts_with("coverage.") || code.starts_with("detector."))
464 .collect();
465 parts.push(format!("not fully inspected ({})", reasons.join(", ")));
466 }
467 if c.semantic > 0 {
468 parts.push(format!("{} semantic finding(s)", c.semantic));
469 }
470 let semantic_gaps: Vec<&str> = codes()
471 .filter(|code| {
472 matches!(*code, "semantic.partial" | "semantic.unavailable")
473 || (code.starts_with("semantic.") && code.ends_with(".not_inspected"))
474 })
475 .collect();
476 if !semantic_gaps.is_empty() {
477 parts.push(format!(
478 "semantic check incomplete ({})",
479 semantic_gaps.join(", ")
480 ));
481 }
482 if withheld {
483 let reasons: Vec<&str> = codes().collect();
484 return Some(format!(
485 "[lean-ctx gateway: content withheld — {}]",
486 reasons.join(", ")
487 ));
488 }
489 (!parts.is_empty()).then(|| format!("[lean-ctx gateway: {}]", parts.join(" · ")))
490}
491
492#[must_use]
502pub fn admit(
503 text: &str,
504 path: Option<&Path>,
505 class: &origin::SourceClass,
506 policy: &AdmissionPolicy,
507) -> Admission {
508 capture::note_origin(class);
509 let floor = class.origin_floor();
510 let object = content_digest(text);
511 let gateway = &policy.gateway;
512 if let Some(codes) = policy.config_refusal_codes() {
513 return Admission {
517 text: None,
518 classification: gateway.mode.unclassified(),
519 trust: TrustLevelV1::Unknown,
520 decision: ContextDecisionV1 {
521 object,
522 disposition: ContextDispositionV1::Deny,
523 reason_codes: codes.iter().map(|code| reason(code)).collect(),
524 signals: Vec::new(),
525 required_transformations: Vec::new(),
526 },
527 counts: AdmissionCounts::default(),
528 };
529 }
530 if !policy.gateway_switch() {
531 return Admission {
534 text: Some(text.to_owned()),
535 classification: gateway.mode.unclassified(),
536 trust: floor,
537 decision: ContextDecisionV1 {
538 object,
539 disposition: ContextDispositionV1::Allow,
540 reason_codes: vec![reason("gateway.disabled")],
541 signals: Vec::new(),
542 required_transformations: Vec::new(),
543 },
544 counts: AdmissionCounts::default(),
545 };
546 }
547
548 let mut actions = policy.actions();
549 if class.is_external_or_unknown()
550 && !capture::egress_check_active()
551 && actions.injection == FilterAction::Warn
552 {
553 actions.injection = FilterAction::Redact;
556 }
557 let findings = detectors::inspect(
558 text,
559 path,
560 &actions,
561 &policy.secret_patterns,
562 policy.budget(),
563 );
564 let mut counts = findings.counts();
565
566 let mut reasons = Vec::new();
567 let mut disposition = ContextDispositionV1::Allow;
568 for incomplete in findings.incomplete() {
571 reasons.push(incomplete);
572 if policy.detectors_mandatory() {
573 disposition = ContextDispositionV1::Deny;
574 }
575 }
576 let mut transformations = Vec::new();
577 for (action, hits, code) in [
578 (actions.secrets, counts.secrets, "secret"),
579 (actions.pii, counts.pii, "pii"),
580 (actions.injection, counts.injection, "injection"),
581 (actions.classification, counts.markings, "classification"),
582 ] {
583 if hits == 0 {
584 continue;
585 }
586 let step = match action {
587 FilterAction::Block => {
588 reasons.push(reason(&format!("{code}.blocked")));
589 ContextDispositionV1::Deny
590 }
591 FilterAction::Redact if code == "classification" => {
593 reasons.push(reason(&format!("{code}.blocked")));
594 ContextDispositionV1::Deny
595 }
596 FilterAction::Redact => {
597 reasons.push(reason(&format!("{code}.redacted")));
598 if !transformations.contains(&TransformationKindV1::Redaction) {
599 transformations.push(TransformationKindV1::Redaction);
600 }
601 ContextDispositionV1::AllowRedacted
602 }
603 FilterAction::Warn => {
604 reasons.push(reason(&format!("{code}.flagged")));
605 ContextDispositionV1::Allow
606 }
607 FilterAction::Off => continue,
608 };
609 disposition = disposition.most_restrictive(step);
610 }
611
612 let semantic = policy
615 .semantic
616 .as_ref()
617 .map(|detect| semantic::run(text, detect, &actions, gateway.max_inspected_bytes));
618 if let Some(outcome) = &semantic {
619 counts.semantic = outcome.hits;
620 for (step, code) in &outcome.steps {
621 reasons.push(code.clone());
622 if *step == ContextDispositionV1::AllowRedacted
623 && !transformations.contains(&TransformationKindV1::Redaction)
624 {
625 transformations.push(TransformationKindV1::Redaction);
626 }
627 disposition = disposition.most_restrictive(*step);
628 }
629 if let Some(code) = &outcome.incomplete {
630 reasons.push(code.clone());
631 }
632 }
633
634 let mut delivered = if disposition == ContextDispositionV1::Deny {
635 None
636 } else {
637 let semantic_masked = semantic
639 .as_ref()
640 .filter(|outcome| !outcome.redactions.is_empty())
641 .map(|outcome| semantic::redact(text, &outcome.redactions));
642 let source = semantic_masked.as_deref().unwrap_or(text);
643 Some(findings.redact(source, &actions, &policy.secret_patterns))
644 };
645 if let Some(output) = &delivered
649 && output != text
650 && detectors::residual(output, &actions, &policy.secret_patterns)
651 {
652 reasons.push(reason("redaction.incomplete"));
653 disposition = ContextDispositionV1::Deny;
654 delivered = None;
655 }
656
657 let classification = findings.classification(gateway.mode, &actions);
658 let semantic_injection = semantic.as_ref().is_some_and(|outcome| {
659 outcome
660 .steps
661 .iter()
662 .any(|(_, code)| {
664 let code = code.as_str();
665 code.starts_with("semantic.prompt_injection") && !code.ends_with(".not_inspected")
666 })
667 });
668 let content_trust = if counts.injection > 0 || semantic_injection {
669 TrustLevelV1::Untrusted
670 } else {
671 TrustLevelV1::Internal
672 };
673 let trust = floor.min(content_trust);
674 let mut signals: Vec<DetectorSignalV1> = findings.signals();
675 if let Some(outcome) = semantic {
676 signals.push(outcome.signal);
677 }
678 Admission {
679 text: delivered,
680 classification,
681 trust,
682 decision: ContextDecisionV1 {
683 object,
684 disposition,
685 reason_codes: reasons,
686 signals,
687 required_transformations: transformations,
688 },
689 counts,
690 }
691}
692
693#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
696pub struct OutputCompleteness {
697 pub truncated: bool,
698 pub timed_out: bool,
699 pub reader_incomplete: bool,
700 pub cancelled: bool,
701 pub decode_lossy: bool,
704}
705
706impl OutputCompleteness {
707 pub const fn complete() -> Self {
708 Self {
709 truncated: false,
710 timed_out: false,
711 reader_incomplete: false,
712 cancelled: false,
713 decode_lossy: false,
714 }
715 }
716
717 pub fn is_complete(self) -> bool {
718 !(self.truncated
719 || self.timed_out
720 || self.reader_incomplete
721 || self.cancelled
722 || self.decode_lossy)
723 }
724
725 fn reason_codes(self) -> Vec<&'static str> {
726 let mut reasons = Vec::new();
727 if self.truncated {
728 reasons.push("coverage.output_truncated");
729 }
730 if self.timed_out {
731 reasons.push("coverage.output_timed_out");
732 }
733 if self.reader_incomplete {
734 reasons.push("coverage.output_reader_incomplete");
735 }
736 if self.cancelled {
737 reasons.push("coverage.output_cancelled");
738 }
739 if self.decode_lossy {
740 reasons.push("coverage.output_decode_lossy");
741 }
742 reasons
743 }
744}
745
746#[derive(Debug, Clone)]
749pub struct ToolOutputAdmission {
750 pub text: Option<String>,
751 pub notice: Option<String>,
752}
753
754impl ToolOutputAdmission {
755 pub fn withheld(&self) -> bool {
756 self.text.is_none()
757 }
758
759 pub fn rendered(&self) -> String {
760 match (&self.text, &self.notice) {
761 (Some(text), Some(notice)) if text.is_empty() => notice.clone(),
762 (Some(text), Some(notice)) => format!("{notice}\n{text}"),
763 (Some(text), None) => text.clone(),
764 (None, Some(notice)) => notice.clone(),
765 (None, None) => "[lean-ctx gateway: content withheld]".to_string(),
766 }
767 }
768}
769
770pub fn admit_tool_output(text: &str, completeness: OutputCompleteness) -> ToolOutputAdmission {
776 let class = capture::current()
777 .and_then(|capture| capture.output_class())
778 .unwrap_or(origin::SourceClass::Unclassified {
779 tool: "tool_output",
780 });
781 admit_tool_output_as(text, completeness, &class)
782}
783
784pub fn admit_tool_output_as(
786 text: &str,
787 completeness: OutputCompleteness,
788 class: &origin::SourceClass,
789) -> ToolOutputAdmission {
790 capture::note_origin(class);
791 let project_original = crate::core::policy::content::protect_active(text);
794 let project_rewrote = project_original
795 .as_ref()
796 .is_ok_and(|admitted| admitted.as_ref() != text);
797 let policy = carrier::current_policy();
798 let mut admission = admit(text, None, class, &policy);
799 mark_capture_incomplete(&mut admission, completeness, &policy);
800
801 let gateway_output = admission.text.clone();
802 let text = project_original
803 .as_deref()
804 .ok()
805 .zip(gateway_output.as_deref())
806 .and_then(|(project_original, gateway_output)| {
807 compose_active_gateway_output(
808 project_original,
809 gateway_output,
810 class,
811 &policy,
812 completeness,
813 )
814 });
815 let policy_withheld = project_original.is_err() || (gateway_output.is_some() && text.is_none());
816 if let Some(admitted) = &text {
817 admission.text = Some(admitted.clone());
818 if project_rewrote
819 || gateway_output
820 .as_deref()
821 .is_some_and(|gateway| gateway != admitted)
822 {
823 mark_project_redaction(&mut admission);
824 }
825 } else if policy_withheld {
826 mark_policy_withheld(&mut admission, "policy.content_withheld");
827 }
828
829 let capture = capture::current();
830 if let Some(capture) = &capture {
831 capture.bind_policy(
832 policy.gateway.mode,
833 fingerprint_digest(&policy.fingerprint()),
834 );
835 capture.record_as(
836 &admission.decision,
837 admission.counts,
838 class.is_external_or_unknown(),
839 );
840 }
841 let mut notice = admission.hud_line_placed(policy.gateway.hud.redaction_counts_in_band());
842 if policy_withheld {
843 notice.get_or_insert_with(|| "[lean-ctx policy: content withheld]".to_string());
844 }
845 ToolOutputAdmission { text, notice }
846}
847
848fn mark_project_redaction(admission: &mut Admission) {
849 admission.decision.disposition = admission
850 .decision
851 .disposition
852 .most_restrictive(ContextDispositionV1::AllowRedacted);
853 let reason = reason("policy.content_redacted");
854 if !admission.decision.reason_codes.contains(&reason) {
855 admission.decision.reason_codes.push(reason);
856 }
857 if !admission
858 .decision
859 .required_transformations
860 .contains(&TransformationKindV1::Redaction)
861 {
862 admission
863 .decision
864 .required_transformations
865 .push(TransformationKindV1::Redaction);
866 }
867}
868
869fn mark_policy_withheld(admission: &mut Admission, reason_code: &str) {
870 admission.text = None;
871 admission.decision.disposition = ContextDispositionV1::Deny;
872 let reason = reason(reason_code);
873 if !admission.decision.reason_codes.contains(&reason) {
874 admission.decision.reason_codes.push(reason);
875 }
876}
877
878pub(crate) fn compose_active_gateway_output(
882 project_original: &str,
883 gateway_output: &str,
884 class: &origin::SourceClass,
885 policy: &AdmissionPolicy,
886 completeness: OutputCompleteness,
887) -> Option<String> {
888 let project_after_gateway = crate::core::policy::content::protect_active(gateway_output)
889 .ok()?
890 .into_owned();
891 let mut gateway_after_project = admit(project_original, None, class, policy);
892 mark_capture_incomplete(&mut gateway_after_project, completeness, policy);
893 let gateway_after_project = gateway_after_project.text?;
894 (project_after_gateway == gateway_after_project).then_some(project_after_gateway)
895}
896
897pub(super) fn admit_output_for_storage(
898 text: &str,
899 completeness: OutputCompleteness,
900 policy: &AdmissionPolicy,
901) -> Option<String> {
902 if !completeness.is_complete() {
905 return None;
906 }
907 let project_original = crate::core::policy::content::protect_active(text).ok()?;
908 let mut admission = admit(text, None, &origin::SourceClass::Project, policy);
909 mark_capture_incomplete(&mut admission, completeness, policy);
910 if !admission.storable() {
911 return None;
912 }
913 let gateway_output = admission.text?;
914 compose_active_gateway_output(
915 project_original.as_ref(),
916 &gateway_output,
917 &origin::SourceClass::Project,
918 policy,
919 completeness,
920 )
921}
922
923pub(super) fn admit_output_for_poll(
928 text: &str,
929 completeness: OutputCompleteness,
930 policy: &AdmissionPolicy,
931) -> Option<String> {
932 let without_reader = OutputCompleteness {
933 reader_incomplete: false,
934 ..completeness
935 };
936 if !completeness.reader_incomplete {
937 return admit_output_for_storage(text, completeness, policy);
938 }
939 if !without_reader.is_complete() || (policy.enabled_effective() && policy.detectors_mandatory())
940 {
941 return None;
942 }
943 admit_output_for_storage(text, without_reader, policy)
944}
945
946fn mark_capture_incomplete(
947 admission: &mut Admission,
948 completeness: OutputCompleteness,
949 policy: &AdmissionPolicy,
950) {
951 if completeness.is_complete() || !policy.enabled_effective() {
952 return;
953 }
954 for code in completeness.reason_codes() {
955 let reason = reason(code);
956 if !admission.decision.reason_codes.contains(&reason) {
957 admission.decision.reason_codes.push(reason);
958 }
959 }
960 let partial_reason = reason("coverage.output_partial");
961 if !admission.decision.reason_codes.contains(&partial_reason) {
962 admission.decision.reason_codes.push(partial_reason.clone());
963 }
964 for signal in &mut admission.decision.signals {
965 if signal.coverage.kind != CoverageKindV1::Complete {
966 continue;
967 }
968 signal.coverage.kind = CoverageKindV1::Partial;
972 signal.coverage.bytes_total = signal.coverage.bytes_inspected.saturating_add(1);
973 signal.coverage.chunks_total = signal.coverage.chunks_inspected.saturating_add(1);
974 signal.coverage.reason = Some(partial_reason.clone());
975 }
976 admission.counts.incomplete = admission.counts.incomplete.max(1);
977 if policy.detectors_mandatory() {
978 admission.decision.disposition = ContextDispositionV1::Deny;
979 admission.text = None;
980 }
981}
982
983#[derive(Debug, Clone, PartialEq, Eq)]
986pub struct SourceAdmission {
987 pub text: String,
988 pub storable: bool,
989 pub(crate) classification: ClassificationV1,
991}
992
993pub fn admit_source(
999 text: &str,
1000 path: &str,
1001 preserve_bytes: bool,
1002 class: &origin::SourceClass,
1003) -> Result<String, std::io::Error> {
1004 admit_source_with_storage(
1005 text,
1006 path,
1007 preserve_bytes,
1008 OutputCompleteness::complete(),
1009 class,
1010 )
1011 .map(|admission| admission.text)
1012}
1013
1014pub fn admit_instance_source(
1017 text: &str,
1018 label: &str,
1019 preserve_bytes: bool,
1020) -> Result<String, std::io::Error> {
1021 admit_source(text, label, preserve_bytes, &origin::SourceClass::Project)
1022}
1023
1024pub fn admit_instance_context_source(text: &str) -> Result<SourceAdmission, std::io::Error> {
1028 admit_context_source(text, &origin::SourceClass::Project)
1029}
1030
1031pub(crate) fn admit_project_file_with_storage(
1034 text: &str,
1035 path: &str,
1036 preserve_bytes: bool,
1037 completeness: OutputCompleteness,
1038) -> Result<SourceAdmission, std::io::Error> {
1039 admit_source_with_storage(
1040 text,
1041 path,
1042 preserve_bytes,
1043 completeness,
1044 &origin::SourceClass::Project,
1045 )
1046}
1047
1048pub(crate) fn admit_source_with_storage(
1051 text: &str,
1052 path: &str,
1053 preserve_bytes: bool,
1054 completeness: OutputCompleteness,
1055 class: &origin::SourceClass,
1056) -> Result<SourceAdmission, std::io::Error> {
1057 admit_source_inner(text, Some(path), preserve_bytes, completeness, None, class)
1058}
1059
1060pub fn admit_context_source(
1063 text: &str,
1064 class: &origin::SourceClass,
1065) -> Result<SourceAdmission, std::io::Error> {
1066 admit_source_inner(
1067 text,
1068 None,
1069 false,
1070 OutputCompleteness::complete(),
1071 None,
1072 class,
1073 )
1074}
1075
1076pub(crate) fn admit_bound_context_source(
1081 text: &str,
1082 class: &origin::SourceClass,
1083) -> Result<SourceAdmission, std::io::Error> {
1084 admit_source_inner(
1085 text,
1086 None,
1087 true,
1088 OutputCompleteness::complete(),
1089 None,
1090 class,
1091 )
1092}
1093
1094pub(crate) fn admit_source_with_snapshot(
1099 text: &str,
1100 path: &str,
1101 preserve_bytes: bool,
1102 completeness: OutputCompleteness,
1103 snapshot: &stores::StoreAdmission,
1104) -> Result<SourceAdmission, std::io::Error> {
1105 admit_source_inner(
1106 text,
1107 Some(path),
1108 preserve_bytes,
1109 completeness,
1110 Some(snapshot.admission_policy()),
1111 &origin::SourceClass::Project,
1112 )
1113}
1114
1115fn admit_source_inner(
1116 text: &str,
1117 path: Option<&str>,
1118 preserve_bytes: bool,
1119 completeness: OutputCompleteness,
1120 policy_snapshot: Option<&AdmissionPolicy>,
1121 class: &origin::SourceClass,
1122) -> Result<SourceAdmission, std::io::Error> {
1123 capture::note_origin(class);
1124 admit_source_typed(
1125 text,
1126 path,
1127 preserve_bytes,
1128 completeness,
1129 policy_snapshot,
1130 class,
1131 )
1132 .map_err(|reasons| {
1133 let hint = withheld_remedy(&reasons);
1134 let reasons: Vec<&str> = reasons.iter().map(ReasonCodeV1::as_str).collect();
1135 std::io::Error::new(
1136 std::io::ErrorKind::PermissionDenied,
1137 format!(
1138 "source content withheld by the context gateway ({}){hint}",
1139 reasons.join(", ")
1140 ),
1141 )
1142 })
1143}
1144
1145fn withheld_remedy(reasons: &[ReasonCodeV1]) -> &'static str {
1149 let has = |code: &str| reasons.iter().any(|reason| reason.as_str() == code);
1150 if has("gateway.config_unloadable") {
1151 " — the global config.toml could not be loaded; fix it (lean-ctx doctor) to restore reads"
1152 } else if has("coverage.partial") || has("coverage.budget_exhausted") {
1153 " — the object exceeds [context_gateway] max_inspected_bytes and governed/sovereign mode \
1154 withholds what detectors cannot fully inspect"
1155 } else if has("detector.timed_out") {
1156 " — a detector exceeded [context_gateway] detector_timeout_ms in governed/sovereign mode"
1157 } else if has("classification.blocked") {
1158 " — a classification marking matched [context_gateway] classification = redact|block"
1159 } else if has("secret.blocked") {
1160 " — a secret matched [context_gateway] secrets = \"block\""
1161 } else if has("injection.blocked") {
1162 " — prompt-injection text matched [context_gateway] injection = \"block\""
1163 } else if has("redaction.incomplete") {
1164 " — a redaction left a detectable value behind, so nothing is delivered"
1165 } else if has("policy.content_withheld") {
1166 " — withheld by the project or organization policy"
1167 } else {
1168 ""
1169 }
1170}
1171
1172fn admit_source_typed(
1175 text: &str,
1176 path: Option<&str>,
1177 preserve_bytes: bool,
1178 completeness: OutputCompleteness,
1179 policy_snapshot: Option<&AdmissionPolicy>,
1180 class: &origin::SourceClass,
1181) -> Result<SourceAdmission, Vec<ReasonCodeV1>> {
1182 let external = class.is_external_or_unknown();
1183 let project_original = crate::core::policy::content::protect_active(text);
1186 let project_rewrote = project_original
1187 .as_ref()
1188 .is_ok_and(|admitted| admitted.as_ref() != text);
1189 let policy = policy_snapshot
1190 .cloned()
1191 .unwrap_or_else(carrier::current_policy);
1192 let fingerprint = policy.fingerprint();
1193 let capture = capture::current();
1194 if let Some(capture) = &capture {
1195 capture.bind_policy(policy.gateway.mode, fingerprint_digest(&fingerprint));
1196 }
1197 let memo_key = path.and_then(|path| {
1200 (completeness.is_complete()
1201 && project_original.is_ok()
1202 && !project_rewrote
1203 && policy.semantic.is_none()
1204 && !policy.config_unloadable()
1205 && policy.gateway_switch())
1206 .then(|| clean::key(&fingerprint, path, text))
1207 });
1208 if let Some(key) = &memo_key
1209 && let Some(decision) = clean::get_source(key)
1210 {
1211 if let Some(capture) = &capture {
1212 capture.record_as(&decision.decision, AdmissionCounts::default(), external);
1213 }
1214 return Ok(SourceAdmission {
1215 text: project_original
1216 .expect("memo key requires accepted project policy")
1217 .into_owned(),
1218 storable: decision.storable,
1219 classification: decision.classification,
1220 });
1221 }
1222
1223 let mut admission = admit(text, path.map(Path::new), class, &policy);
1224 mark_capture_incomplete(&mut admission, completeness, &policy);
1225 let storable = completeness.is_complete() && admission.storable();
1226 let gateway_admitted = admission.text.clone();
1227 let mut project_admitted = project_original
1228 .as_deref()
1229 .ok()
1230 .zip(gateway_admitted.as_deref())
1231 .and_then(|(project_original, gateway_admitted)| {
1232 compose_active_gateway_output(
1233 project_original,
1234 gateway_admitted,
1235 class,
1236 &policy,
1237 completeness,
1238 )
1239 });
1240 let mut refusal_reason = if project_original.is_err()
1241 || (gateway_admitted.is_some() && project_admitted.is_none())
1242 {
1243 Some("policy.content_withheld")
1244 } else {
1245 None
1246 };
1247
1248 if let Some(admitted) = &project_admitted {
1249 if project_rewrote
1250 || gateway_admitted
1251 .as_deref()
1252 .is_some_and(|gateway| gateway != admitted)
1253 {
1254 admission.text = Some(admitted.clone());
1255 mark_project_redaction(&mut admission);
1256 }
1257 }
1258 if preserve_bytes
1259 && let Some(gateway_admitted) = gateway_admitted.as_deref()
1260 && let Some(project_admitted_text) = project_admitted.as_deref()
1261 && preserve_bytes_rewrite_reason(
1262 text,
1263 gateway_admitted,
1264 project_admitted_text,
1265 project_rewrote,
1266 )
1267 .is_some()
1268 {
1269 project_admitted = None;
1270 refusal_reason = Some("source.bytes_not_preserved");
1271 }
1272 if let Some(reason_code) = refusal_reason {
1273 mark_policy_withheld(&mut admission, reason_code);
1274 }
1275 if let Some(capture) = &capture {
1276 capture.record_as(&admission.decision, admission.counts, external);
1277 }
1278
1279 let Some(project_admitted) = project_admitted else {
1280 return Err(admission.decision.reason_codes);
1281 };
1282
1283 if let Some(key) = memo_key
1284 && admission.disposition() == ContextDispositionV1::Allow
1285 && admission.decision.reason_codes.is_empty()
1286 && admission.text.as_deref() == Some(text)
1287 && project_admitted == text
1288 {
1289 clean::insert_source(
1290 key,
1291 admission.decision.clone(),
1292 admission.classification,
1293 admission.storable(),
1294 );
1295 }
1296 let redaction_counts = policy.gateway.hud.redaction_counts_in_band();
1297 if admission.hud_line_placed(redaction_counts).is_some() {
1298 notes::record(
1299 project_admitted.as_ref(),
1300 notes::Note {
1301 counts: admission.counts,
1302 decision: admission.decision.clone(),
1303 redaction_counts,
1304 },
1305 );
1306 }
1307 Ok(SourceAdmission {
1308 text: project_admitted,
1309 storable,
1310 classification: admission.classification,
1311 })
1312}
1313
1314fn preserve_bytes_rewrite_reason(
1315 source: &str,
1316 gateway_admitted: &str,
1317 project_admitted: &str,
1318 project_rewrote_original: bool,
1319) -> Option<&'static str> {
1320 if project_rewrote_original || project_admitted != gateway_admitted {
1321 Some("source content withheld: an active policy would rewrite a digest-bound source")
1322 } else if gateway_admitted != source {
1323 Some("source content withheld: the context gateway would rewrite a digest-bound source")
1324 } else {
1325 None
1326 }
1327}
1328
1329#[must_use]
1333pub fn admit_uninspectable(path: Option<&Path>, bytes: u64, policy: &AdmissionPolicy) -> Admission {
1334 let gateway = &policy.gateway;
1335 let object = content_digest(&format!("uninspectable:{bytes}"));
1336 let actions = policy.actions();
1337 let signals = detectors::unsupported_signals(&actions, bytes);
1338 let required = signals.iter().any(|signal| {
1339 signal.coverage.kind == lean_ctx_protocol::context_gateway::CoverageKindV1::Unsupported
1340 });
1341 if policy.config_unloadable() || !policy.gateway_switch() || !required {
1342 return admit("", path, &origin::SourceClass::Project, policy);
1343 }
1344 let restricted_path =
1345 path.is_some_and(|p| crate::core::io_boundary::is_secret_like(p).is_some());
1346 let mandatory = policy.detectors_mandatory();
1347 Admission {
1348 text: (!mandatory).then(String::new),
1349 classification: if restricted_path {
1350 ClassificationV1::Restricted
1351 } else {
1352 gateway.mode.unclassified()
1353 },
1354 trust: TrustLevelV1::Unknown,
1355 decision: ContextDecisionV1 {
1356 object,
1357 disposition: if mandatory {
1358 ContextDispositionV1::Deny
1359 } else {
1360 ContextDispositionV1::Allow
1361 },
1362 reason_codes: vec![reason("coverage.unsupported_media")],
1363 signals,
1364 required_transformations: Vec::new(),
1365 },
1366 counts: AdmissionCounts {
1367 incomplete: 1,
1368 ..AdmissionCounts::default()
1369 },
1370 }
1371}
1372
1373pub fn admit_media(path: &str, bytes: u64) -> Result<Option<String>, std::io::Error> {
1376 let policy = carrier::current_policy();
1377 let admission = admit_uninspectable(Some(Path::new(path)), bytes, &policy);
1378 if let Some(capture) = capture::current() {
1379 capture.bind_policy(
1380 policy.gateway.mode,
1381 fingerprint_digest(&policy.fingerprint()),
1382 );
1383 capture.record(&admission.decision, admission.counts);
1384 }
1385 if admission.text.is_none() {
1386 return Err(std::io::Error::new(
1387 std::io::ErrorKind::PermissionDenied,
1388 "media withheld by the context gateway (coverage.unsupported_media)",
1389 ));
1390 }
1391 Ok((admission.counts.incomplete > 0)
1392 .then(|| "[lean-ctx gateway: media not inspected (coverage.unsupported_media)]".to_owned()))
1393}
1394
1395#[cfg(any(test, feature = "test-support"))]
1397pub fn clear_clean_memo() {
1398 clean::clear();
1399}
1400
1401#[must_use]
1408pub fn note_for(admitted: &str, delivered: &str) -> Option<String> {
1409 let note = notes::lookup(admitted)?;
1410 let mut counts = note.counts;
1411 if note.decision.disposition == ContextDispositionV1::AllowRedacted {
1412 let pii = crate::core::input_filters::pii::checksummed_marker_count(delivered);
1413 let other = delivered
1414 .matches("[REDACTED:")
1415 .count()
1416 .saturating_sub(pii + delivered.matches(INJECTION_MARKER).count());
1417 counts.pii = counts.pii.min(saturating_u32(pii));
1418 counts.secrets = counts.secrets.min(saturating_u32(other));
1419 }
1420 render_hud(counts, ¬e.decision, false, note.redaction_counts)
1421}
1422
1423const INJECTION_MARKER: &str = "[REDACTED:prompt-injection]";
1424
1425fn saturating_u32(n: usize) -> u32 {
1426 u32::try_from(n).unwrap_or(u32::MAX)
1427}
1428
1429fn reason(code: &str) -> ReasonCodeV1 {
1430 ReasonCodeV1::new(code).expect("built-in reason codes are valid")
1431}
1432
1433pub(crate) fn record_recheck_refusal(
1441 admission: &stores::StoreAdmission,
1442 validity: lean_ctx_protocol::context_lineage::SourceValidityV1,
1443) {
1444 let Some(capture) = capture::current() else {
1445 return;
1446 };
1447 let policy = admission.admission_policy();
1448 capture.bind_policy(
1449 policy.gateway.mode,
1450 fingerprint_digest(&policy.fingerprint()),
1451 );
1452 let decision = ContextDecisionV1 {
1453 object: fingerprint_digest(b"source-recheck-refused"),
1454 disposition: ContextDispositionV1::Deny,
1455 reason_codes: vec![reason(validity.reason_code())],
1456 signals: Vec::new(),
1457 required_transformations: Vec::new(),
1458 };
1459 capture.record(&decision, AdmissionCounts::default());
1460}
1461
1462fn fingerprint_digest(fingerprint: &[u8]) -> Sha256Digest {
1464 let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(fingerprint));
1465 Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
1466}
1467
1468fn content_digest(text: &str) -> Sha256Digest {
1469 let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(text.as_bytes()));
1470 Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
1471}
1472
1473#[cfg(test)]
1474mod shell_admission_regression_tests;
1475
1476#[cfg(test)]
1477mod tests;
1478
1479#[cfg(test)]
1480mod receipt_tests;
1481
1482#[cfg(test)]
1483mod store_tests;
1484
1485#[cfg(test)]
1486mod egress_tests;
1487
1488#[cfg(test)]
1489mod egress_api_tests;