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 let reported = (actions.secrets == FilterAction::Redact && counts.secrets > 0)
651 || (actions.pii == FilterAction::Redact && counts.pii > 0);
652 if let Some(output) = &delivered
653 && (output != text || reported)
654 && detectors::residual(output, &actions, &policy.secret_patterns)
655 {
656 reasons.push(reason("redaction.incomplete"));
657 disposition = ContextDispositionV1::Deny;
658 delivered = None;
659 }
660
661 let classification = findings.classification(gateway.mode, &actions);
662 let semantic_injection = semantic.as_ref().is_some_and(|outcome| {
663 outcome
664 .steps
665 .iter()
666 .any(|(_, code)| {
668 let code = code.as_str();
669 code.starts_with("semantic.prompt_injection") && !code.ends_with(".not_inspected")
670 })
671 });
672 let content_trust = if counts.injection > 0 || semantic_injection {
673 TrustLevelV1::Untrusted
674 } else {
675 TrustLevelV1::Internal
676 };
677 let trust = floor.min(content_trust);
678 let mut signals: Vec<DetectorSignalV1> = findings.signals();
679 if let Some(outcome) = semantic {
680 signals.push(outcome.signal);
681 }
682 Admission {
683 text: delivered,
684 classification,
685 trust,
686 decision: ContextDecisionV1 {
687 object,
688 disposition,
689 reason_codes: reasons,
690 signals,
691 required_transformations: transformations,
692 },
693 counts,
694 }
695}
696
697#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
700pub struct OutputCompleteness {
701 pub truncated: bool,
702 pub timed_out: bool,
703 pub reader_incomplete: bool,
704 pub cancelled: bool,
705 pub decode_lossy: bool,
708}
709
710impl OutputCompleteness {
711 pub const fn complete() -> Self {
712 Self {
713 truncated: false,
714 timed_out: false,
715 reader_incomplete: false,
716 cancelled: false,
717 decode_lossy: false,
718 }
719 }
720
721 pub fn is_complete(self) -> bool {
722 !(self.truncated
723 || self.timed_out
724 || self.reader_incomplete
725 || self.cancelled
726 || self.decode_lossy)
727 }
728
729 fn reason_codes(self) -> Vec<&'static str> {
730 let mut reasons = Vec::new();
731 if self.truncated {
732 reasons.push("coverage.output_truncated");
733 }
734 if self.timed_out {
735 reasons.push("coverage.output_timed_out");
736 }
737 if self.reader_incomplete {
738 reasons.push("coverage.output_reader_incomplete");
739 }
740 if self.cancelled {
741 reasons.push("coverage.output_cancelled");
742 }
743 if self.decode_lossy {
744 reasons.push("coverage.output_decode_lossy");
745 }
746 reasons
747 }
748}
749
750#[derive(Debug, Clone)]
753pub struct ToolOutputAdmission {
754 pub text: Option<String>,
755 pub notice: Option<String>,
756}
757
758impl ToolOutputAdmission {
759 pub fn withheld(&self) -> bool {
760 self.text.is_none()
761 }
762
763 pub fn rendered(&self) -> String {
764 match (&self.text, &self.notice) {
765 (Some(text), Some(notice)) if text.is_empty() => notice.clone(),
766 (Some(text), Some(notice)) => format!("{notice}\n{text}"),
767 (Some(text), None) => text.clone(),
768 (None, Some(notice)) => notice.clone(),
769 (None, None) => "[lean-ctx gateway: content withheld]".to_string(),
770 }
771 }
772}
773
774pub fn admit_tool_output(text: &str, completeness: OutputCompleteness) -> ToolOutputAdmission {
780 let class = capture::current()
781 .and_then(|capture| capture.output_class())
782 .unwrap_or(origin::SourceClass::Unclassified {
783 tool: "tool_output",
784 });
785 admit_tool_output_as(text, completeness, &class)
786}
787
788pub fn admit_tool_output_as(
790 text: &str,
791 completeness: OutputCompleteness,
792 class: &origin::SourceClass,
793) -> ToolOutputAdmission {
794 capture::note_origin(class);
795 let project_original = crate::core::policy::content::protect_active(text);
798 let project_rewrote = project_original
799 .as_ref()
800 .is_ok_and(|admitted| admitted.as_ref() != text);
801 let policy = carrier::current_policy();
802 let mut admission = admit(text, None, class, &policy);
803 mark_capture_incomplete(&mut admission, completeness, &policy);
804
805 let gateway_output = admission.text.clone();
806 let text = project_original
807 .as_deref()
808 .ok()
809 .zip(gateway_output.as_deref())
810 .and_then(|(project_original, gateway_output)| {
811 compose_active_gateway_output(
812 project_original,
813 gateway_output,
814 class,
815 &policy,
816 completeness,
817 )
818 });
819 let policy_withheld = project_original.is_err() || (gateway_output.is_some() && text.is_none());
820 if let Some(admitted) = &text {
821 admission.text = Some(admitted.clone());
822 if project_rewrote
823 || gateway_output
824 .as_deref()
825 .is_some_and(|gateway| gateway != admitted)
826 {
827 mark_project_redaction(&mut admission);
828 }
829 } else if policy_withheld {
830 mark_policy_withheld(&mut admission, "policy.content_withheld");
831 }
832
833 let capture = capture::current();
834 if let Some(capture) = &capture {
835 capture.bind_policy(
836 policy.gateway.mode,
837 fingerprint_digest(&policy.fingerprint()),
838 );
839 capture.record_as(
840 &admission.decision,
841 admission.counts,
842 class.is_external_or_unknown(),
843 );
844 }
845 let mut notice = admission.hud_line_placed(policy.gateway.hud.redaction_counts_in_band());
846 if policy_withheld {
847 notice.get_or_insert_with(|| "[lean-ctx policy: content withheld]".to_string());
848 }
849 ToolOutputAdmission { text, notice }
850}
851
852fn mark_project_redaction(admission: &mut Admission) {
853 admission.decision.disposition = admission
854 .decision
855 .disposition
856 .most_restrictive(ContextDispositionV1::AllowRedacted);
857 let reason = reason("policy.content_redacted");
858 if !admission.decision.reason_codes.contains(&reason) {
859 admission.decision.reason_codes.push(reason);
860 }
861 if !admission
862 .decision
863 .required_transformations
864 .contains(&TransformationKindV1::Redaction)
865 {
866 admission
867 .decision
868 .required_transformations
869 .push(TransformationKindV1::Redaction);
870 }
871}
872
873fn mark_policy_withheld(admission: &mut Admission, reason_code: &str) {
874 admission.text = None;
875 admission.decision.disposition = ContextDispositionV1::Deny;
876 let reason = reason(reason_code);
877 if !admission.decision.reason_codes.contains(&reason) {
878 admission.decision.reason_codes.push(reason);
879 }
880}
881
882pub(crate) fn compose_active_gateway_output(
886 project_original: &str,
887 gateway_output: &str,
888 class: &origin::SourceClass,
889 policy: &AdmissionPolicy,
890 completeness: OutputCompleteness,
891) -> Option<String> {
892 let project_after_gateway = crate::core::policy::content::protect_active(gateway_output)
893 .ok()?
894 .into_owned();
895 let mut gateway_after_project = admit(project_original, None, class, policy);
896 mark_capture_incomplete(&mut gateway_after_project, completeness, policy);
897 let gateway_after_project = gateway_after_project.text?;
898 (project_after_gateway == gateway_after_project).then_some(project_after_gateway)
899}
900
901pub(super) fn admit_output_for_storage(
902 text: &str,
903 completeness: OutputCompleteness,
904 policy: &AdmissionPolicy,
905) -> Option<String> {
906 if !completeness.is_complete() {
909 return None;
910 }
911 let project_original = crate::core::policy::content::protect_active(text).ok()?;
912 let mut admission = admit(text, None, &origin::SourceClass::Project, policy);
913 mark_capture_incomplete(&mut admission, completeness, policy);
914 if !admission.storable() {
915 return None;
916 }
917 let gateway_output = admission.text?;
918 compose_active_gateway_output(
919 project_original.as_ref(),
920 &gateway_output,
921 &origin::SourceClass::Project,
922 policy,
923 completeness,
924 )
925}
926
927pub(super) fn admit_output_for_poll(
932 text: &str,
933 completeness: OutputCompleteness,
934 policy: &AdmissionPolicy,
935) -> Option<String> {
936 let without_reader = OutputCompleteness {
937 reader_incomplete: false,
938 ..completeness
939 };
940 if !completeness.reader_incomplete {
941 return admit_output_for_storage(text, completeness, policy);
942 }
943 if !without_reader.is_complete() || (policy.enabled_effective() && policy.detectors_mandatory())
944 {
945 return None;
946 }
947 admit_output_for_storage(text, without_reader, policy)
948}
949
950fn mark_capture_incomplete(
951 admission: &mut Admission,
952 completeness: OutputCompleteness,
953 policy: &AdmissionPolicy,
954) {
955 if completeness.is_complete() || !policy.enabled_effective() {
956 return;
957 }
958 for code in completeness.reason_codes() {
959 let reason = reason(code);
960 if !admission.decision.reason_codes.contains(&reason) {
961 admission.decision.reason_codes.push(reason);
962 }
963 }
964 let partial_reason = reason("coverage.output_partial");
965 if !admission.decision.reason_codes.contains(&partial_reason) {
966 admission.decision.reason_codes.push(partial_reason.clone());
967 }
968 for signal in &mut admission.decision.signals {
969 if signal.coverage.kind != CoverageKindV1::Complete {
970 continue;
971 }
972 signal.coverage.kind = CoverageKindV1::Partial;
976 signal.coverage.bytes_total = signal.coverage.bytes_inspected.saturating_add(1);
977 signal.coverage.chunks_total = signal.coverage.chunks_inspected.saturating_add(1);
978 signal.coverage.reason = Some(partial_reason.clone());
979 }
980 admission.counts.incomplete = admission.counts.incomplete.max(1);
981 if policy.detectors_mandatory() {
982 admission.decision.disposition = ContextDispositionV1::Deny;
983 admission.text = None;
984 }
985}
986
987#[derive(Debug, Clone, PartialEq, Eq)]
990pub struct SourceAdmission {
991 pub text: String,
992 pub storable: bool,
993 pub(crate) classification: ClassificationV1,
995}
996
997pub fn admit_source(
1003 text: &str,
1004 path: &str,
1005 preserve_bytes: bool,
1006 class: &origin::SourceClass,
1007) -> Result<String, std::io::Error> {
1008 admit_source_with_storage(
1009 text,
1010 path,
1011 preserve_bytes,
1012 OutputCompleteness::complete(),
1013 class,
1014 )
1015 .map(|admission| admission.text)
1016}
1017
1018pub fn admit_instance_source(
1021 text: &str,
1022 label: &str,
1023 preserve_bytes: bool,
1024) -> Result<String, std::io::Error> {
1025 admit_source(text, label, preserve_bytes, &origin::SourceClass::Project)
1026}
1027
1028pub fn admit_instance_context_source(text: &str) -> Result<SourceAdmission, std::io::Error> {
1032 admit_context_source(text, &origin::SourceClass::Project)
1033}
1034
1035pub(crate) fn admit_project_file_with_storage(
1038 text: &str,
1039 path: &str,
1040 preserve_bytes: bool,
1041 completeness: OutputCompleteness,
1042) -> Result<SourceAdmission, std::io::Error> {
1043 admit_source_with_storage(
1044 text,
1045 path,
1046 preserve_bytes,
1047 completeness,
1048 &origin::SourceClass::Project,
1049 )
1050}
1051
1052pub(crate) fn admit_source_with_storage(
1055 text: &str,
1056 path: &str,
1057 preserve_bytes: bool,
1058 completeness: OutputCompleteness,
1059 class: &origin::SourceClass,
1060) -> Result<SourceAdmission, std::io::Error> {
1061 admit_source_inner(text, Some(path), preserve_bytes, completeness, None, class)
1062}
1063
1064pub fn admit_context_source(
1067 text: &str,
1068 class: &origin::SourceClass,
1069) -> Result<SourceAdmission, std::io::Error> {
1070 admit_source_inner(
1071 text,
1072 None,
1073 false,
1074 OutputCompleteness::complete(),
1075 None,
1076 class,
1077 )
1078}
1079
1080pub(crate) fn admit_bound_context_source(
1085 text: &str,
1086 class: &origin::SourceClass,
1087) -> Result<SourceAdmission, std::io::Error> {
1088 admit_source_inner(
1089 text,
1090 None,
1091 true,
1092 OutputCompleteness::complete(),
1093 None,
1094 class,
1095 )
1096}
1097
1098pub(crate) fn admit_source_with_snapshot(
1103 text: &str,
1104 path: &str,
1105 preserve_bytes: bool,
1106 completeness: OutputCompleteness,
1107 snapshot: &stores::StoreAdmission,
1108) -> Result<SourceAdmission, std::io::Error> {
1109 admit_source_inner(
1110 text,
1111 Some(path),
1112 preserve_bytes,
1113 completeness,
1114 Some(snapshot.admission_policy()),
1115 &origin::SourceClass::Project,
1116 )
1117}
1118
1119fn admit_source_inner(
1120 text: &str,
1121 path: Option<&str>,
1122 preserve_bytes: bool,
1123 completeness: OutputCompleteness,
1124 policy_snapshot: Option<&AdmissionPolicy>,
1125 class: &origin::SourceClass,
1126) -> Result<SourceAdmission, std::io::Error> {
1127 capture::note_origin(class);
1128 admit_source_typed(
1129 text,
1130 path,
1131 preserve_bytes,
1132 completeness,
1133 policy_snapshot,
1134 class,
1135 )
1136 .map_err(|reasons| {
1137 let hint = withheld_remedy(&reasons);
1138 let reasons: Vec<&str> = reasons.iter().map(ReasonCodeV1::as_str).collect();
1139 std::io::Error::new(
1140 std::io::ErrorKind::PermissionDenied,
1141 format!(
1142 "source content withheld by the context gateway ({}){hint}",
1143 reasons.join(", ")
1144 ),
1145 )
1146 })
1147}
1148
1149fn withheld_remedy(reasons: &[ReasonCodeV1]) -> &'static str {
1153 let has = |code: &str| reasons.iter().any(|reason| reason.as_str() == code);
1154 if has("gateway.config_unloadable") {
1155 " — the global config.toml could not be loaded; fix it (lean-ctx doctor) to restore reads"
1156 } else if has("coverage.partial") || has("coverage.budget_exhausted") {
1157 " — the object exceeds [context_gateway] max_inspected_bytes and governed/sovereign mode \
1158 withholds what detectors cannot fully inspect"
1159 } else if has("detector.timed_out") {
1160 " — a detector exceeded [context_gateway] detector_timeout_ms in governed/sovereign mode"
1161 } else if has("classification.blocked") {
1162 " — a classification marking matched [context_gateway] classification = redact|block"
1163 } else if has("secret.blocked") {
1164 " — a secret matched [context_gateway] secrets = \"block\""
1165 } else if has("injection.blocked") {
1166 " — prompt-injection text matched [context_gateway] injection = \"block\""
1167 } else if has("redaction.incomplete") {
1168 " — a redaction left a detectable value behind, so nothing is delivered"
1169 } else if has("policy.content_withheld") {
1170 " — withheld by the project or organization policy"
1171 } else {
1172 ""
1173 }
1174}
1175
1176fn admit_source_typed(
1179 text: &str,
1180 path: Option<&str>,
1181 preserve_bytes: bool,
1182 completeness: OutputCompleteness,
1183 policy_snapshot: Option<&AdmissionPolicy>,
1184 class: &origin::SourceClass,
1185) -> Result<SourceAdmission, Vec<ReasonCodeV1>> {
1186 let external = class.is_external_or_unknown();
1187 let project_original = crate::core::policy::content::protect_active(text);
1190 let project_rewrote = project_original
1191 .as_ref()
1192 .is_ok_and(|admitted| admitted.as_ref() != text);
1193 let policy = policy_snapshot
1194 .cloned()
1195 .unwrap_or_else(carrier::current_policy);
1196 let fingerprint = policy.fingerprint();
1197 let capture = capture::current();
1198 if let Some(capture) = &capture {
1199 capture.bind_policy(policy.gateway.mode, fingerprint_digest(&fingerprint));
1200 }
1201 let memo_key = path.and_then(|path| {
1204 (completeness.is_complete()
1205 && project_original.is_ok()
1206 && !project_rewrote
1207 && policy.semantic.is_none()
1208 && !policy.config_unloadable()
1209 && policy.gateway_switch())
1210 .then(|| clean::key(&fingerprint, path, text))
1211 });
1212 if let Some(key) = &memo_key
1213 && let Some(decision) = clean::get_source(key)
1214 {
1215 if let Some(capture) = &capture {
1216 capture.record_as(&decision.decision, AdmissionCounts::default(), external);
1217 }
1218 return Ok(SourceAdmission {
1219 text: project_original
1220 .expect("memo key requires accepted project policy")
1221 .into_owned(),
1222 storable: decision.storable,
1223 classification: decision.classification,
1224 });
1225 }
1226
1227 let mut admission = admit(text, path.map(Path::new), class, &policy);
1228 mark_capture_incomplete(&mut admission, completeness, &policy);
1229 let storable = completeness.is_complete() && admission.storable();
1230 let gateway_admitted = admission.text.clone();
1231 let mut project_admitted = project_original
1232 .as_deref()
1233 .ok()
1234 .zip(gateway_admitted.as_deref())
1235 .and_then(|(project_original, gateway_admitted)| {
1236 compose_active_gateway_output(
1237 project_original,
1238 gateway_admitted,
1239 class,
1240 &policy,
1241 completeness,
1242 )
1243 });
1244 let mut refusal_reason = if project_original.is_err()
1245 || (gateway_admitted.is_some() && project_admitted.is_none())
1246 {
1247 Some("policy.content_withheld")
1248 } else {
1249 None
1250 };
1251
1252 if let Some(admitted) = &project_admitted {
1253 if project_rewrote
1254 || gateway_admitted
1255 .as_deref()
1256 .is_some_and(|gateway| gateway != admitted)
1257 {
1258 admission.text = Some(admitted.clone());
1259 mark_project_redaction(&mut admission);
1260 }
1261 }
1262 if preserve_bytes
1263 && let Some(gateway_admitted) = gateway_admitted.as_deref()
1264 && let Some(project_admitted_text) = project_admitted.as_deref()
1265 && preserve_bytes_rewrite_reason(
1266 text,
1267 gateway_admitted,
1268 project_admitted_text,
1269 project_rewrote,
1270 )
1271 .is_some()
1272 {
1273 project_admitted = None;
1274 refusal_reason = Some("source.bytes_not_preserved");
1275 }
1276 if let Some(reason_code) = refusal_reason {
1277 mark_policy_withheld(&mut admission, reason_code);
1278 }
1279 if let Some(capture) = &capture {
1280 capture.record_as(&admission.decision, admission.counts, external);
1281 }
1282
1283 let Some(project_admitted) = project_admitted else {
1284 return Err(admission.decision.reason_codes);
1285 };
1286
1287 if let Some(key) = memo_key
1288 && admission.disposition() == ContextDispositionV1::Allow
1289 && admission.decision.reason_codes.is_empty()
1290 && admission.text.as_deref() == Some(text)
1291 && project_admitted == text
1292 {
1293 clean::insert_source(
1294 key,
1295 admission.decision.clone(),
1296 admission.classification,
1297 admission.storable(),
1298 );
1299 }
1300 let redaction_counts = policy.gateway.hud.redaction_counts_in_band();
1301 if admission.hud_line_placed(redaction_counts).is_some() {
1302 notes::record(
1303 project_admitted.as_ref(),
1304 notes::Note {
1305 counts: admission.counts,
1306 decision: admission.decision.clone(),
1307 redaction_counts,
1308 },
1309 );
1310 }
1311 Ok(SourceAdmission {
1312 text: project_admitted,
1313 storable,
1314 classification: admission.classification,
1315 })
1316}
1317
1318fn preserve_bytes_rewrite_reason(
1319 source: &str,
1320 gateway_admitted: &str,
1321 project_admitted: &str,
1322 project_rewrote_original: bool,
1323) -> Option<&'static str> {
1324 if project_rewrote_original || project_admitted != gateway_admitted {
1325 Some("source content withheld: an active policy would rewrite a digest-bound source")
1326 } else if gateway_admitted != source {
1327 Some("source content withheld: the context gateway would rewrite a digest-bound source")
1328 } else {
1329 None
1330 }
1331}
1332
1333#[must_use]
1337pub fn admit_uninspectable(path: Option<&Path>, bytes: u64, policy: &AdmissionPolicy) -> Admission {
1338 let gateway = &policy.gateway;
1339 let object = content_digest(&format!("uninspectable:{bytes}"));
1340 let actions = policy.actions();
1341 let signals = detectors::unsupported_signals(&actions, bytes);
1342 let required = signals.iter().any(|signal| {
1343 signal.coverage.kind == lean_ctx_protocol::context_gateway::CoverageKindV1::Unsupported
1344 });
1345 if policy.config_unloadable() || !policy.gateway_switch() || !required {
1346 return admit("", path, &origin::SourceClass::Project, policy);
1347 }
1348 let restricted_path =
1349 path.is_some_and(|p| crate::core::io_boundary::is_secret_like(p).is_some());
1350 let mandatory = policy.detectors_mandatory();
1351 Admission {
1352 text: (!mandatory).then(String::new),
1353 classification: if restricted_path {
1354 ClassificationV1::Restricted
1355 } else {
1356 gateway.mode.unclassified()
1357 },
1358 trust: TrustLevelV1::Unknown,
1359 decision: ContextDecisionV1 {
1360 object,
1361 disposition: if mandatory {
1362 ContextDispositionV1::Deny
1363 } else {
1364 ContextDispositionV1::Allow
1365 },
1366 reason_codes: vec![reason("coverage.unsupported_media")],
1367 signals,
1368 required_transformations: Vec::new(),
1369 },
1370 counts: AdmissionCounts {
1371 incomplete: 1,
1372 ..AdmissionCounts::default()
1373 },
1374 }
1375}
1376
1377pub fn admit_media(path: &str, bytes: u64) -> Result<Option<String>, std::io::Error> {
1380 let policy = carrier::current_policy();
1381 let admission = admit_uninspectable(Some(Path::new(path)), bytes, &policy);
1382 if let Some(capture) = capture::current() {
1383 capture.bind_policy(
1384 policy.gateway.mode,
1385 fingerprint_digest(&policy.fingerprint()),
1386 );
1387 capture.record(&admission.decision, admission.counts);
1388 }
1389 if admission.text.is_none() {
1390 return Err(std::io::Error::new(
1391 std::io::ErrorKind::PermissionDenied,
1392 "media withheld by the context gateway (coverage.unsupported_media)",
1393 ));
1394 }
1395 Ok((admission.counts.incomplete > 0)
1396 .then(|| "[lean-ctx gateway: media not inspected (coverage.unsupported_media)]".to_owned()))
1397}
1398
1399#[cfg(any(test, feature = "test-support"))]
1401pub fn clear_clean_memo() {
1402 clean::clear();
1403}
1404
1405#[must_use]
1412pub fn note_for(admitted: &str, delivered: &str) -> Option<String> {
1413 let note = notes::lookup(admitted)?;
1414 let mut counts = note.counts;
1415 if note.decision.disposition == ContextDispositionV1::AllowRedacted {
1416 let pii = crate::core::input_filters::pii::checksummed_marker_count(delivered);
1417 let other = delivered
1418 .matches("[REDACTED:")
1419 .count()
1420 .saturating_sub(pii + delivered.matches(INJECTION_MARKER).count());
1421 counts.pii = counts.pii.min(saturating_u32(pii));
1422 counts.secrets = counts.secrets.min(saturating_u32(other));
1423 }
1424 render_hud(counts, ¬e.decision, false, note.redaction_counts)
1425}
1426
1427const INJECTION_MARKER: &str = "[REDACTED:prompt-injection]";
1428
1429fn saturating_u32(n: usize) -> u32 {
1430 u32::try_from(n).unwrap_or(u32::MAX)
1431}
1432
1433fn reason(code: &str) -> ReasonCodeV1 {
1434 ReasonCodeV1::new(code).expect("built-in reason codes are valid")
1435}
1436
1437pub(crate) fn record_recheck_refusal(
1445 admission: &stores::StoreAdmission,
1446 validity: lean_ctx_protocol::context_lineage::SourceValidityV1,
1447) {
1448 let Some(capture) = capture::current() else {
1449 return;
1450 };
1451 let policy = admission.admission_policy();
1452 capture.bind_policy(
1453 policy.gateway.mode,
1454 fingerprint_digest(&policy.fingerprint()),
1455 );
1456 let decision = ContextDecisionV1 {
1457 object: fingerprint_digest(b"source-recheck-refused"),
1458 disposition: ContextDispositionV1::Deny,
1459 reason_codes: vec![reason(validity.reason_code())],
1460 signals: Vec::new(),
1461 required_transformations: Vec::new(),
1462 };
1463 capture.record(&decision, AdmissionCounts::default());
1464}
1465
1466fn fingerprint_digest(fingerprint: &[u8]) -> Sha256Digest {
1468 let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(fingerprint));
1469 Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
1470}
1471
1472fn content_digest(text: &str) -> Sha256Digest {
1473 let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(text.as_bytes()));
1474 Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
1475}
1476
1477#[cfg(test)]
1478mod shell_admission_regression_tests;
1479
1480#[cfg(test)]
1481mod tests;
1482
1483#[cfg(test)]
1484mod operand_tests;
1485
1486#[cfg(test)]
1487mod receipt_tests;
1488
1489#[cfg(test)]
1490mod store_tests;
1491
1492#[cfg(test)]
1493mod egress_tests;
1494
1495#[cfg(test)]
1496mod egress_api_tests;