Skip to main content

lean_ctx/core/context_admission/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Context admission: the one local decision every governed source passes
3//! *before* it is cached, compressed, indexed or delivered.
4//!
5//! `admit` runs the built-in detectors (secrets, checksum-validated PII,
6//! prompt injection, classification markers, secret-like paths) over the
7//! original text, decides one [`ContextDispositionV1`], applies the required
8//! transformations and re-checks the result. Blocking signals are evaluated
9//! before any redaction can hide them, and a redaction that leaves a
10//! detectable secret behind withholds the content instead (fail closed).
11//!
12//! The result carries the canonical `context_gateway` vocabulary — signals
13//! with honest coverage, reason codes, classification — and never the matched
14//! values. Defaults are on (owner decision E2); each detector can be tuned in
15//! the global `[context_gateway]` section, which project-local configuration
16//! cannot weaken.
17
18pub 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/// `[context_gateway]` — global only; never merged from a project-local file.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(default)]
53pub struct ContextGatewayConfig {
54    /// Master switch. `LEAN_CTX_CONTEXT_GATEWAY=off` overrides it.
55    pub enabled: bool,
56    pub mode: GatewayModeV1,
57    /// Credentials and keys (patterns from `[secret_detection]`).
58    pub secrets: FilterAction,
59    /// Checksum-validated PII only: payment cards.
60    pub pii: FilterAction,
61    /// Prompt-injection heuristic (OWASP LLM01).
62    pub injection: FilterAction,
63    /// Explicit classification markings (`CONFIDENTIAL`, `classification: …`).
64    pub classification: FilterAction,
65    /// Bytes each detector may inspect per object; beyond it, coverage is
66    /// `partial`. Governed and sovereign modes withhold partially inspected
67    /// content, developer mode delivers it with a visible note.
68    pub max_inspected_bytes: usize,
69    /// Per-detector time budget; a detector that runs out reports `timed_out`.
70    pub detector_timeout_ms: u64,
71    /// Additional placement constraints applied to every final egress target.
72    /// These remain active when content scanning is disabled.
73    pub destination_constraints: Vec<DestinationConstraintV1>,
74    /// Optional target allow-list. Provider and locality are always checked;
75    /// model/account/region are checked when the configured entry supplies them.
76    /// Unknown locality never matches. Account references are configured labels,
77    /// not provider-verified identities.
78    pub allowed_destinations: Vec<DestinationV1>,
79    /// Where redaction counts are shown (see `hud`).
80    pub hud: hud::HudPlacement,
81}
82
83/// Same bound as policy-pack content inspection (`policy::content`).
84pub 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    /// Effective master switch. The `LEAN_CTX_CONTEXT_GATEWAY` kill switch
107    /// (`0|false|off`) is a developer convenience: it disables admission only
108    /// in developer mode. A governed or sovereign deployment is configured by
109    /// its operator in the global file; a process environment cannot switch
110    /// its protection off, so the variable is ignored there (and reported, see
111    /// [`Self::kill_switch`]). Any other value still forces the gateway on.
112    #[must_use]
113    pub fn enabled_effective(&self) -> bool {
114        self.enabled_under(EnvSwitch::read())
115    }
116
117    /// The effective switch under one captured environment state. Admission
118    /// uses only this form, with the state its `AdmissionPolicy` captured:
119    /// the live environment is read once per snapshot, never per decision.
120    #[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    /// What the `LEAN_CTX_CONTEXT_GATEWAY` environment variable does under
130    /// this configuration (for `doctor` and the admission log). Reads the
131    /// live environment.
132    #[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    /// Destination policy is a hard authorization gate, independent of the
158    /// optional content-detector switch.
159    #[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    /// Check the final, trusted destination against configured placement rules.
165    #[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/// The raw state of `LEAN_CTX_CONTEXT_GATEWAY`, read once and carried in the
195/// admission snapshot. It is the only environment-derived input of admission.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub(crate) enum EnvSwitch {
198    Unset,
199    /// `0|false|off`.
200    Off,
201    /// Any other value.
202    On,
203}
204
205impl EnvSwitch {
206    /// The live environment, now.
207    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/// Effect of the `LEAN_CTX_CONTEXT_GATEWAY` environment variable.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum KillSwitch {
219    Unset,
220    /// Set to a value other than `0|false|off`: the gateway is on.
221    ForcedOn,
222    /// `off` in developer mode: admission is disabled (`gateway.disabled`).
223    Honoured,
224    /// `off` in governed or sovereign mode: has no effect.
225    Ignored,
226}
227
228/// Everything `admit` needs, resolved once from the global configuration.
229#[derive(Debug, Clone)]
230pub struct AdmissionPolicy {
231    pub gateway: ContextGatewayConfig,
232    /// Pattern source for the secrets detector. An explicit
233    /// `secret_detection.enabled = false` turns the secrets detector off.
234    pub secret_patterns: SecretDetectionConfig,
235    /// Optional licensed semantic detectors (advisory; see `semantic`).
236    pub(crate) semantic: Option<semantic::SemanticDetector>,
237    /// Set when the snapshot this policy was resolved from could not apply the
238    /// global configuration, so `gateway` holds built-in defaults, not the
239    /// operator's settings. The configured mode is unknown: nothing is
240    /// admitted in this state, in any mode.
241    load_failure: Option<crate::core::config::ConfigLoadFailure>,
242    /// The environment switch as it was when this snapshot was taken.
243    env_switch: EnvSwitch,
244}
245
246impl AdmissionPolicy {
247    /// Resolve from a configuration snapshot. Whether the operator's settings
248    /// were applied is read from that same snapshot (`Config::load_failure`),
249    /// never from the process-global parse diagnostic.
250    #[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    /// Built-in detectors only, from explicit settings.
262    #[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    /// Whether this policy stands in for a global configuration that could
274    /// not be loaded (see [`Self::from_config`]).
275    #[must_use]
276    pub fn config_unloadable(&self) -> bool {
277        self.load_failure.is_some()
278    }
279
280    /// Reason codes of the refusal for an unloadable configuration: the one
281    /// stable admission code, then the loader's specific code. Never the
282    /// parse error text, which can quote file content.
283    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    /// The master switch as admission applies it: an unloadable configuration
289    /// can never be "disabled", whatever its default values or the
290    /// environment say.
291    #[must_use]
292    pub fn enabled_effective(&self) -> bool {
293        self.config_unloadable() || self.gateway_switch()
294    }
295
296    /// The gateway's own switch under the environment state this snapshot
297    /// captured when it was built. The fingerprint and every decision taken
298    /// with this policy use exactly this value; the live environment is not
299    /// consulted again. Whether the snapshot is still current is a separate,
300    /// explicit question (`egress::PolicyEpoch::is_current`).
301    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    /// Canonical bytes of everything that decides an admission: the gateway
330    /// settings and the secret patterns. Keys the clean memo and is hashed into
331    /// every receipt's policy reference.
332    fn fingerprint(&self) -> Vec<u8> {
333        let mut value = serde_json::json!({
334            "gateway": &self.gateway,
335            "secret_patterns": &self.secret_patterns,
336            // `false` without a detector (bytes unchanged); otherwise the
337            // identity of the runtime this policy's detector is bound to.
338            "semantic": self
339                .semantic
340                .as_ref()
341                .map_or(serde_json::Value::Bool(false), semantic::SemanticDetector::identity),
342        });
343        // Added only in the failure state, so every digest recorded under a
344        // loadable configuration keeps its bytes.
345        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        // The effective switch is an input of every decision, and the
351        // environment can move it without touching a configuration byte
352        // (`LEAN_CTX_CONTEXT_GATEWAY`). It is bound whenever it differs from
353        // the configured value — and only then, so digests taken without an
354        // environment override keep their bytes.
355        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    /// Governed and sovereign modes treat every enabled detector as mandatory:
365    /// content it could not fully inspect is withheld.
366    fn detectors_mandatory(&self) -> bool {
367        self.gateway.mode != GatewayModeV1::Developer
368    }
369}
370
371/// Content-free counts for the HUD line and the receipt.
372#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
373pub struct AdmissionCounts {
374    pub secrets: u32,
375    pub pii: u32,
376    /// 1 when the object carries prompt-injection signals: an object is
377    /// flagged once, however many of its lines matched.
378    pub injection: u32,
379    /// Which pattern families flagged it
380    /// ([`crate::core::output_sanitizer::InjectionCategory`] bits).
381    pub injection_patterns: u8,
382    pub markings: u32,
383    /// Enabled detectors that did not inspect the whole object.
384    pub incomplete: u32,
385    /// Findings of the optional semantic detectors.
386    pub semantic: u32,
387}
388
389/// The outcome of admitting one object.
390#[derive(Debug, Clone)]
391pub struct Admission {
392    /// `None` when the content is withheld; never the original then.
393    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    /// Whether the delivered text may enter derived stores (caches, indexes,
407    /// archives). Restricted content never does (owner decision E3).
408    #[must_use]
409    pub fn storable(&self) -> bool {
410        self.text.is_some() && self.classification < ClassificationV1::Restricted
411    }
412
413    /// One stable, content-free line describing what the gateway did, or
414    /// `None` when it changed and flagged nothing.
415    #[must_use]
416    pub fn hud_line(&self) -> Option<String> {
417        self.hud_line_placed(true)
418    }
419
420    /// [`Self::hud_line`] for the model-facing output. With
421    /// `redaction_counts == false` (the host shows lean-ctx out of band),
422    /// counts of masked values are left to the status line: the markers in the
423    /// content already tell the model, the human sees the count elsewhere.
424    /// Detected-but-delivered values, untrusted content and incomplete
425    /// inspection are always stated, because the model must act on them.
426    #[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
437/// The HUD line for `counts` under `decision`; see [`Admission::hud_line_placed`].
438fn 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/// Admit `text` (from `path`, when it came from a file) of origin `class`
493/// under `policy`.
494///
495/// Trust is `min(origin_floor(class), content_trust)` (pz21 §1.2). An
496/// external or unknown class is noted in the call's origin ledger first,
497/// before any early return, and its injection action is raised from `Warn`
498/// to `Redact` where the text is delivered or stored for delivery (a
499/// detached store write inside `outside_call` included), never inside an
500/// egress-equality check (`capture::egress_check`, NB-2).
501#[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        // The operator's mode and detector actions are unknown. Running the
514        // built-in defaults here would silently turn a governed installation
515        // into a developer one.
516        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        // Developer mode skips the detectors, not the origin (N-2): a
532        // project file stays `Internal`, outside text keeps its floor.
533        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        // Redaction default for untrusted or unknown text (pz21 §1.3 item 2):
554        // only the flagged lines; governed modes may already block.
555        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    // A detector that did not inspect everything never reports "clean": its
569    // reason is always recorded, and mandatory detectors withhold the object.
570    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            // A marking classifies the whole object; there is no span to mask.
592            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    // Optional semantic detectors: advisory, so an incomplete run is reported
613    // but only an actual finding can tighten the decision.
614    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        // Semantic spans refer to the original text, so they are masked first.
638        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    // Fail closed: a redaction that leaves a detectable secret or PII value
646    // behind must not be delivered as if it were clean. Unchanged text was
647    // already inspected by the same detectors, so only a rewrite is re-checked.
648    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            // A finding, not the statement that the category was not inspected.
663            .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/// Capture facts from a shell process. The flags describe raw byte streams,
694/// before decoding or rendering; marker text is never used to infer completeness.
695#[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    /// Set conservatively when captured bytes required lossy text decoding.
702    /// On Windows the legacy ACP decoder may also be lossy for some code pages.
703    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/// Result of inspecting tool output. A missing text means delivery is denied;
747/// rendered then contains only the stable, content-free gateway notice.
748#[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
770/// Output of the command the current call classified (`ctx_shell` sets the
771/// class of every command it runs, pz21 §1.8). Without a classified call the
772/// origin is unknown, never assumed to be the project's: a caller that knows
773/// its command or source passes the class to [`admit_tool_output_as`]
774/// (`patterns::tool_output::output_class`).
775pub 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
784/// [`admit_tool_output`] for output of origin `class`.
785pub fn admit_tool_output_as(
786    text: &str,
787    completeness: OutputCompleteness,
788    class: &origin::SourceClass,
789) -> ToolOutputAdmission {
790    capture::note_origin(class);
791    // Check active project policy against the original bytes before Gateway
792    // redaction can hide a phrase that policy must block.
793    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
878/// Compose the project-policy and Gateway transformations without losing either
879/// detector's view of the original input. If their order changes the result,
880/// withholding is safer than publishing text whose provenance is ambiguous.
881pub(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    // Incomplete process output is never cached or retained for later polls,
903    // even in Developer mode where the originating call may see a warning.
904    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
923/// A finished background job's output, kept until a status poll delivers it
924/// (#2164). Only a held pipe (a process the command left running) is
925/// tolerated; deliveries still pass `admit_tool_output` with the real flags,
926/// and truncated, timed-out, cancelled or lossy captures stay unretained.
927pub(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        // The process capture knows only the prefix length. One unknown byte is
969        // the protocol-valid lower bound for the omitted tail; it is not a claim
970        // that the command emitted exactly one additional byte.
971        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/// Source text admitted for delivery, with the original classification's
984/// persistence decision preserved for downstream caches and indexes.
985#[derive(Debug, Clone, PartialEq, Eq)]
986pub struct SourceAdmission {
987    pub text: String,
988    pub storable: bool,
989    /// Classification assigned by the original Gateway inspection.
990    pub(crate) classification: ClassificationV1,
991}
992
993/// Admit a source the agent is about to read, at the read choke point.
994///
995/// Returns the admitted text, or a content-free `PermissionDenied` naming the
996/// reason codes. With `preserve_bytes` (signed snapshots) any rewrite is
997/// refused instead of silently changing a digest-bound source.
998pub 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
1014/// [`admit_source`] for text the Instance assembled itself (protocol text,
1015/// its own knowledge store): origin Project (pz21 §1.4).
1016pub 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
1024/// [`admit_context_source`] for text the Instance assembled from its own
1025/// project and records (a checkout diff, receipts, an engine source):
1026/// origin Project (pz21 §1.4).
1027pub fn admit_instance_context_source(text: &str) -> Result<SourceAdmission, std::io::Error> {
1028    admit_context_source(text, &origin::SourceClass::Project)
1029}
1030
1031/// [`admit_source_with_storage`] for a rooted read of a project file (the
1032/// rooted reader and the read cache it fills): origin Project (pz21 §1.4).
1033pub(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
1048/// Admit a source and preserve whether its original Gateway classification may
1049/// enter derived stores. Incomplete reads never seed the clean memo or stores.
1050pub(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
1060/// Admit an in-memory source of origin `class` without assigning it a
1061/// synthetic filesystem path.
1062pub 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
1076/// [`admit_context_source`] for text that is bound by a digest or signature
1077/// and therefore cannot be delivered rewritten: a mask is a refusal, and it
1078/// is recorded as one (never as an admitted redaction of a copy that did not
1079/// leave).
1080pub(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
1094/// Admit a rooted source with one caller-captured Gateway snapshot. Hosted
1095/// generation builders use this so a multi-file build cannot mix policies if
1096/// global configuration changes during the scan; publication must reauthorize
1097/// the same snapshot afterward.
1098pub(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
1145/// The setting behind a withheld read, so the user can tell an intended policy
1146/// from a misconfiguration (814 refusals in 30 days carried only the code —
1147/// focus analysis 2026-10-15). Fixed text: never content, never a path.
1148fn 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
1172/// [`admit_source_inner`] with the refusal as typed reason codes, for carriers
1173/// that render their own content-free message (see `carrier`).
1174fn 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    // Inspect original content before a Gateway redaction or clean memo can
1184    // remove a signal required by the active project policy.
1185    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    // Clean memo: only complete reads and built-in detectors are pure functions
1198    // of the input. Partial or lossy reads must be inspected with their flags.
1199    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/// Admit an object no built-in detector can read (an image). Every enabled
1330/// detector reports `unsupported`; developer mode delivers it with a note,
1331/// governed and sovereign modes withhold it.
1332#[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
1373/// Media gate for the read path: `Ok(note)` to deliver with a visible note,
1374/// or a content-free `PermissionDenied` when the media must be withheld.
1375pub 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/// Forget every memoized clean admission (measurements of cold reads).
1396#[cfg(any(test, feature = "test-support"))]
1397pub fn clear_clean_memo() {
1398    clean::clear();
1399}
1400
1401/// The HUD line recorded when exactly `admitted` was produced, stated for
1402/// `delivered`: the part of `admitted` a tool actually returns (a line
1403/// window, a compressed view). Masked values are counted from the markers
1404/// `delivered` carries, never above the object's own counts, so a window
1405/// without a masked value gets no redaction count — and no line when nothing
1406/// else applies (#2135). Every other part of the note describes the object.
1407#[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, &note.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
1433/// Record in the running call's receipt that a derived copy admitted before
1434/// (at its cache fill) was asked of its source again at this serve and
1435/// refused: the after side of the revocation boundary (C04). The decision is
1436/// a denial whose reason is the source state (`source.revoked`, …); its
1437/// object is one fixed digest for every such refusal, so the receipt names
1438/// neither the item nor its text and reads the same for every item withheld
1439/// this way (K02/S02). No capture: nothing is recorded.
1440pub(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
1462/// SHA-256 of the canonical policy fingerprint: the receipt's policy digest.
1463fn 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;