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