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. A rewrite is always
647    // re-checked; so is unchanged text after a redacting detector reported a
648    // value, because the redactor then kept what the detector flagged
649    // (GHSA-xmx8-69qv-7f86). Clean, unchanged text needs no second pass.
650    let reported = (actions.secrets == FilterAction::Redact && counts.secrets > 0)
651        || (actions.pii == FilterAction::Redact && counts.pii > 0);
652    if let Some(output) = &delivered
653        && (output != text || reported)
654        && detectors::residual(output, &actions, &policy.secret_patterns)
655    {
656        reasons.push(reason("redaction.incomplete"));
657        disposition = ContextDispositionV1::Deny;
658        delivered = None;
659    }
660
661    let classification = findings.classification(gateway.mode, &actions);
662    let semantic_injection = semantic.as_ref().is_some_and(|outcome| {
663        outcome
664            .steps
665            .iter()
666            // A finding, not the statement that the category was not inspected.
667            .any(|(_, code)| {
668                let code = code.as_str();
669                code.starts_with("semantic.prompt_injection") && !code.ends_with(".not_inspected")
670            })
671    });
672    let content_trust = if counts.injection > 0 || semantic_injection {
673        TrustLevelV1::Untrusted
674    } else {
675        TrustLevelV1::Internal
676    };
677    let trust = floor.min(content_trust);
678    let mut signals: Vec<DetectorSignalV1> = findings.signals();
679    if let Some(outcome) = semantic {
680        signals.push(outcome.signal);
681    }
682    Admission {
683        text: delivered,
684        classification,
685        trust,
686        decision: ContextDecisionV1 {
687            object,
688            disposition,
689            reason_codes: reasons,
690            signals,
691            required_transformations: transformations,
692        },
693        counts,
694    }
695}
696
697/// Capture facts from a shell process. The flags describe raw byte streams,
698/// before decoding or rendering; marker text is never used to infer completeness.
699#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
700pub struct OutputCompleteness {
701    pub truncated: bool,
702    pub timed_out: bool,
703    pub reader_incomplete: bool,
704    pub cancelled: bool,
705    /// Set conservatively when captured bytes required lossy text decoding.
706    /// On Windows the legacy ACP decoder may also be lossy for some code pages.
707    pub decode_lossy: bool,
708}
709
710impl OutputCompleteness {
711    pub const fn complete() -> Self {
712        Self {
713            truncated: false,
714            timed_out: false,
715            reader_incomplete: false,
716            cancelled: false,
717            decode_lossy: false,
718        }
719    }
720
721    pub fn is_complete(self) -> bool {
722        !(self.truncated
723            || self.timed_out
724            || self.reader_incomplete
725            || self.cancelled
726            || self.decode_lossy)
727    }
728
729    fn reason_codes(self) -> Vec<&'static str> {
730        let mut reasons = Vec::new();
731        if self.truncated {
732            reasons.push("coverage.output_truncated");
733        }
734        if self.timed_out {
735            reasons.push("coverage.output_timed_out");
736        }
737        if self.reader_incomplete {
738            reasons.push("coverage.output_reader_incomplete");
739        }
740        if self.cancelled {
741            reasons.push("coverage.output_cancelled");
742        }
743        if self.decode_lossy {
744            reasons.push("coverage.output_decode_lossy");
745        }
746        reasons
747    }
748}
749
750/// Result of inspecting tool output. A missing text means delivery is denied;
751/// rendered then contains only the stable, content-free gateway notice.
752#[derive(Debug, Clone)]
753pub struct ToolOutputAdmission {
754    pub text: Option<String>,
755    pub notice: Option<String>,
756}
757
758impl ToolOutputAdmission {
759    pub fn withheld(&self) -> bool {
760        self.text.is_none()
761    }
762
763    pub fn rendered(&self) -> String {
764        match (&self.text, &self.notice) {
765            (Some(text), Some(notice)) if text.is_empty() => notice.clone(),
766            (Some(text), Some(notice)) => format!("{notice}\n{text}"),
767            (Some(text), None) => text.clone(),
768            (None, Some(notice)) => notice.clone(),
769            (None, None) => "[lean-ctx gateway: content withheld]".to_string(),
770        }
771    }
772}
773
774/// Output of the command the current call classified (`ctx_shell` sets the
775/// class of every command it runs, pz21 §1.8). Without a classified call the
776/// origin is unknown, never assumed to be the project's: a caller that knows
777/// its command or source passes the class to [`admit_tool_output_as`]
778/// (`patterns::tool_output::output_class`).
779pub fn admit_tool_output(text: &str, completeness: OutputCompleteness) -> ToolOutputAdmission {
780    let class = capture::current()
781        .and_then(|capture| capture.output_class())
782        .unwrap_or(origin::SourceClass::Unclassified {
783            tool: "tool_output",
784        });
785    admit_tool_output_as(text, completeness, &class)
786}
787
788/// [`admit_tool_output`] for output of origin `class`.
789pub fn admit_tool_output_as(
790    text: &str,
791    completeness: OutputCompleteness,
792    class: &origin::SourceClass,
793) -> ToolOutputAdmission {
794    capture::note_origin(class);
795    // Check active project policy against the original bytes before Gateway
796    // redaction can hide a phrase that policy must block.
797    let project_original = crate::core::policy::content::protect_active(text);
798    let project_rewrote = project_original
799        .as_ref()
800        .is_ok_and(|admitted| admitted.as_ref() != text);
801    let policy = carrier::current_policy();
802    let mut admission = admit(text, None, class, &policy);
803    mark_capture_incomplete(&mut admission, completeness, &policy);
804
805    let gateway_output = admission.text.clone();
806    let text = project_original
807        .as_deref()
808        .ok()
809        .zip(gateway_output.as_deref())
810        .and_then(|(project_original, gateway_output)| {
811            compose_active_gateway_output(
812                project_original,
813                gateway_output,
814                class,
815                &policy,
816                completeness,
817            )
818        });
819    let policy_withheld = project_original.is_err() || (gateway_output.is_some() && text.is_none());
820    if let Some(admitted) = &text {
821        admission.text = Some(admitted.clone());
822        if project_rewrote
823            || gateway_output
824                .as_deref()
825                .is_some_and(|gateway| gateway != admitted)
826        {
827            mark_project_redaction(&mut admission);
828        }
829    } else if policy_withheld {
830        mark_policy_withheld(&mut admission, "policy.content_withheld");
831    }
832
833    let capture = capture::current();
834    if let Some(capture) = &capture {
835        capture.bind_policy(
836            policy.gateway.mode,
837            fingerprint_digest(&policy.fingerprint()),
838        );
839        capture.record_as(
840            &admission.decision,
841            admission.counts,
842            class.is_external_or_unknown(),
843        );
844    }
845    let mut notice = admission.hud_line_placed(policy.gateway.hud.redaction_counts_in_band());
846    if policy_withheld {
847        notice.get_or_insert_with(|| "[lean-ctx policy: content withheld]".to_string());
848    }
849    ToolOutputAdmission { text, notice }
850}
851
852fn mark_project_redaction(admission: &mut Admission) {
853    admission.decision.disposition = admission
854        .decision
855        .disposition
856        .most_restrictive(ContextDispositionV1::AllowRedacted);
857    let reason = reason("policy.content_redacted");
858    if !admission.decision.reason_codes.contains(&reason) {
859        admission.decision.reason_codes.push(reason);
860    }
861    if !admission
862        .decision
863        .required_transformations
864        .contains(&TransformationKindV1::Redaction)
865    {
866        admission
867            .decision
868            .required_transformations
869            .push(TransformationKindV1::Redaction);
870    }
871}
872
873fn mark_policy_withheld(admission: &mut Admission, reason_code: &str) {
874    admission.text = None;
875    admission.decision.disposition = ContextDispositionV1::Deny;
876    let reason = reason(reason_code);
877    if !admission.decision.reason_codes.contains(&reason) {
878        admission.decision.reason_codes.push(reason);
879    }
880}
881
882/// Compose the project-policy and Gateway transformations without losing either
883/// detector's view of the original input. If their order changes the result,
884/// withholding is safer than publishing text whose provenance is ambiguous.
885pub(crate) fn compose_active_gateway_output(
886    project_original: &str,
887    gateway_output: &str,
888    class: &origin::SourceClass,
889    policy: &AdmissionPolicy,
890    completeness: OutputCompleteness,
891) -> Option<String> {
892    let project_after_gateway = crate::core::policy::content::protect_active(gateway_output)
893        .ok()?
894        .into_owned();
895    let mut gateway_after_project = admit(project_original, None, class, policy);
896    mark_capture_incomplete(&mut gateway_after_project, completeness, policy);
897    let gateway_after_project = gateway_after_project.text?;
898    (project_after_gateway == gateway_after_project).then_some(project_after_gateway)
899}
900
901pub(super) fn admit_output_for_storage(
902    text: &str,
903    completeness: OutputCompleteness,
904    policy: &AdmissionPolicy,
905) -> Option<String> {
906    // Incomplete process output is never cached or retained for later polls,
907    // even in Developer mode where the originating call may see a warning.
908    if !completeness.is_complete() {
909        return None;
910    }
911    let project_original = crate::core::policy::content::protect_active(text).ok()?;
912    let mut admission = admit(text, None, &origin::SourceClass::Project, policy);
913    mark_capture_incomplete(&mut admission, completeness, policy);
914    if !admission.storable() {
915        return None;
916    }
917    let gateway_output = admission.text?;
918    compose_active_gateway_output(
919        project_original.as_ref(),
920        &gateway_output,
921        &origin::SourceClass::Project,
922        policy,
923        completeness,
924    )
925}
926
927/// A finished background job's output, kept until a status poll delivers it
928/// (#2164). Only a held pipe (a process the command left running) is
929/// tolerated; deliveries still pass `admit_tool_output` with the real flags,
930/// and truncated, timed-out, cancelled or lossy captures stay unretained.
931pub(super) fn admit_output_for_poll(
932    text: &str,
933    completeness: OutputCompleteness,
934    policy: &AdmissionPolicy,
935) -> Option<String> {
936    let without_reader = OutputCompleteness {
937        reader_incomplete: false,
938        ..completeness
939    };
940    if !completeness.reader_incomplete {
941        return admit_output_for_storage(text, completeness, policy);
942    }
943    if !without_reader.is_complete() || (policy.enabled_effective() && policy.detectors_mandatory())
944    {
945        return None;
946    }
947    admit_output_for_storage(text, without_reader, policy)
948}
949
950fn mark_capture_incomplete(
951    admission: &mut Admission,
952    completeness: OutputCompleteness,
953    policy: &AdmissionPolicy,
954) {
955    if completeness.is_complete() || !policy.enabled_effective() {
956        return;
957    }
958    for code in completeness.reason_codes() {
959        let reason = reason(code);
960        if !admission.decision.reason_codes.contains(&reason) {
961            admission.decision.reason_codes.push(reason);
962        }
963    }
964    let partial_reason = reason("coverage.output_partial");
965    if !admission.decision.reason_codes.contains(&partial_reason) {
966        admission.decision.reason_codes.push(partial_reason.clone());
967    }
968    for signal in &mut admission.decision.signals {
969        if signal.coverage.kind != CoverageKindV1::Complete {
970            continue;
971        }
972        // The process capture knows only the prefix length. One unknown byte is
973        // the protocol-valid lower bound for the omitted tail; it is not a claim
974        // that the command emitted exactly one additional byte.
975        signal.coverage.kind = CoverageKindV1::Partial;
976        signal.coverage.bytes_total = signal.coverage.bytes_inspected.saturating_add(1);
977        signal.coverage.chunks_total = signal.coverage.chunks_inspected.saturating_add(1);
978        signal.coverage.reason = Some(partial_reason.clone());
979    }
980    admission.counts.incomplete = admission.counts.incomplete.max(1);
981    if policy.detectors_mandatory() {
982        admission.decision.disposition = ContextDispositionV1::Deny;
983        admission.text = None;
984    }
985}
986
987/// Source text admitted for delivery, with the original classification's
988/// persistence decision preserved for downstream caches and indexes.
989#[derive(Debug, Clone, PartialEq, Eq)]
990pub struct SourceAdmission {
991    pub text: String,
992    pub storable: bool,
993    /// Classification assigned by the original Gateway inspection.
994    pub(crate) classification: ClassificationV1,
995}
996
997/// Admit a source the agent is about to read, at the read choke point.
998///
999/// Returns the admitted text, or a content-free `PermissionDenied` naming the
1000/// reason codes. With `preserve_bytes` (signed snapshots) any rewrite is
1001/// refused instead of silently changing a digest-bound source.
1002pub fn admit_source(
1003    text: &str,
1004    path: &str,
1005    preserve_bytes: bool,
1006    class: &origin::SourceClass,
1007) -> Result<String, std::io::Error> {
1008    admit_source_with_storage(
1009        text,
1010        path,
1011        preserve_bytes,
1012        OutputCompleteness::complete(),
1013        class,
1014    )
1015    .map(|admission| admission.text)
1016}
1017
1018/// [`admit_source`] for text the Instance assembled itself (protocol text,
1019/// its own knowledge store): origin Project (pz21 §1.4).
1020pub fn admit_instance_source(
1021    text: &str,
1022    label: &str,
1023    preserve_bytes: bool,
1024) -> Result<String, std::io::Error> {
1025    admit_source(text, label, preserve_bytes, &origin::SourceClass::Project)
1026}
1027
1028/// [`admit_context_source`] for text the Instance assembled from its own
1029/// project and records (a checkout diff, receipts, an engine source):
1030/// origin Project (pz21 §1.4).
1031pub fn admit_instance_context_source(text: &str) -> Result<SourceAdmission, std::io::Error> {
1032    admit_context_source(text, &origin::SourceClass::Project)
1033}
1034
1035/// [`admit_source_with_storage`] for a rooted read of a project file (the
1036/// rooted reader and the read cache it fills): origin Project (pz21 §1.4).
1037pub(crate) fn admit_project_file_with_storage(
1038    text: &str,
1039    path: &str,
1040    preserve_bytes: bool,
1041    completeness: OutputCompleteness,
1042) -> Result<SourceAdmission, std::io::Error> {
1043    admit_source_with_storage(
1044        text,
1045        path,
1046        preserve_bytes,
1047        completeness,
1048        &origin::SourceClass::Project,
1049    )
1050}
1051
1052/// Admit a source and preserve whether its original Gateway classification may
1053/// enter derived stores. Incomplete reads never seed the clean memo or stores.
1054pub(crate) fn admit_source_with_storage(
1055    text: &str,
1056    path: &str,
1057    preserve_bytes: bool,
1058    completeness: OutputCompleteness,
1059    class: &origin::SourceClass,
1060) -> Result<SourceAdmission, std::io::Error> {
1061    admit_source_inner(text, Some(path), preserve_bytes, completeness, None, class)
1062}
1063
1064/// Admit an in-memory source of origin `class` without assigning it a
1065/// synthetic filesystem path.
1066pub fn admit_context_source(
1067    text: &str,
1068    class: &origin::SourceClass,
1069) -> Result<SourceAdmission, std::io::Error> {
1070    admit_source_inner(
1071        text,
1072        None,
1073        false,
1074        OutputCompleteness::complete(),
1075        None,
1076        class,
1077    )
1078}
1079
1080/// [`admit_context_source`] for text that is bound by a digest or signature
1081/// and therefore cannot be delivered rewritten: a mask is a refusal, and it
1082/// is recorded as one (never as an admitted redaction of a copy that did not
1083/// leave).
1084pub(crate) fn admit_bound_context_source(
1085    text: &str,
1086    class: &origin::SourceClass,
1087) -> Result<SourceAdmission, std::io::Error> {
1088    admit_source_inner(
1089        text,
1090        None,
1091        true,
1092        OutputCompleteness::complete(),
1093        None,
1094        class,
1095    )
1096}
1097
1098/// Admit a rooted source with one caller-captured Gateway snapshot. Hosted
1099/// generation builders use this so a multi-file build cannot mix policies if
1100/// global configuration changes during the scan; publication must reauthorize
1101/// the same snapshot afterward.
1102pub(crate) fn admit_source_with_snapshot(
1103    text: &str,
1104    path: &str,
1105    preserve_bytes: bool,
1106    completeness: OutputCompleteness,
1107    snapshot: &stores::StoreAdmission,
1108) -> Result<SourceAdmission, std::io::Error> {
1109    admit_source_inner(
1110        text,
1111        Some(path),
1112        preserve_bytes,
1113        completeness,
1114        Some(snapshot.admission_policy()),
1115        &origin::SourceClass::Project,
1116    )
1117}
1118
1119fn admit_source_inner(
1120    text: &str,
1121    path: Option<&str>,
1122    preserve_bytes: bool,
1123    completeness: OutputCompleteness,
1124    policy_snapshot: Option<&AdmissionPolicy>,
1125    class: &origin::SourceClass,
1126) -> Result<SourceAdmission, std::io::Error> {
1127    capture::note_origin(class);
1128    admit_source_typed(
1129        text,
1130        path,
1131        preserve_bytes,
1132        completeness,
1133        policy_snapshot,
1134        class,
1135    )
1136    .map_err(|reasons| {
1137        let hint = withheld_remedy(&reasons);
1138        let reasons: Vec<&str> = reasons.iter().map(ReasonCodeV1::as_str).collect();
1139        std::io::Error::new(
1140            std::io::ErrorKind::PermissionDenied,
1141            format!(
1142                "source content withheld by the context gateway ({}){hint}",
1143                reasons.join(", ")
1144            ),
1145        )
1146    })
1147}
1148
1149/// The setting behind a withheld read, so the user can tell an intended policy
1150/// from a misconfiguration (814 refusals in 30 days carried only the code —
1151/// focus analysis 2026-10-15). Fixed text: never content, never a path.
1152fn withheld_remedy(reasons: &[ReasonCodeV1]) -> &'static str {
1153    let has = |code: &str| reasons.iter().any(|reason| reason.as_str() == code);
1154    if has("gateway.config_unloadable") {
1155        " — the global config.toml could not be loaded; fix it (lean-ctx doctor) to restore reads"
1156    } else if has("coverage.partial") || has("coverage.budget_exhausted") {
1157        " — the object exceeds [context_gateway] max_inspected_bytes and governed/sovereign mode \
1158         withholds what detectors cannot fully inspect"
1159    } else if has("detector.timed_out") {
1160        " — a detector exceeded [context_gateway] detector_timeout_ms in governed/sovereign mode"
1161    } else if has("classification.blocked") {
1162        " — a classification marking matched [context_gateway] classification = redact|block"
1163    } else if has("secret.blocked") {
1164        " — a secret matched [context_gateway] secrets = \"block\""
1165    } else if has("injection.blocked") {
1166        " — prompt-injection text matched [context_gateway] injection = \"block\""
1167    } else if has("redaction.incomplete") {
1168        " — a redaction left a detectable value behind, so nothing is delivered"
1169    } else if has("policy.content_withheld") {
1170        " — withheld by the project or organization policy"
1171    } else {
1172        ""
1173    }
1174}
1175
1176/// [`admit_source_inner`] with the refusal as typed reason codes, for carriers
1177/// that render their own content-free message (see `carrier`).
1178fn admit_source_typed(
1179    text: &str,
1180    path: Option<&str>,
1181    preserve_bytes: bool,
1182    completeness: OutputCompleteness,
1183    policy_snapshot: Option<&AdmissionPolicy>,
1184    class: &origin::SourceClass,
1185) -> Result<SourceAdmission, Vec<ReasonCodeV1>> {
1186    let external = class.is_external_or_unknown();
1187    // Inspect original content before a Gateway redaction or clean memo can
1188    // remove a signal required by the active project policy.
1189    let project_original = crate::core::policy::content::protect_active(text);
1190    let project_rewrote = project_original
1191        .as_ref()
1192        .is_ok_and(|admitted| admitted.as_ref() != text);
1193    let policy = policy_snapshot
1194        .cloned()
1195        .unwrap_or_else(carrier::current_policy);
1196    let fingerprint = policy.fingerprint();
1197    let capture = capture::current();
1198    if let Some(capture) = &capture {
1199        capture.bind_policy(policy.gateway.mode, fingerprint_digest(&fingerprint));
1200    }
1201    // Clean memo: only complete reads and built-in detectors are pure functions
1202    // of the input. Partial or lossy reads must be inspected with their flags.
1203    let memo_key = path.and_then(|path| {
1204        (completeness.is_complete()
1205            && project_original.is_ok()
1206            && !project_rewrote
1207            && policy.semantic.is_none()
1208            && !policy.config_unloadable()
1209            && policy.gateway_switch())
1210        .then(|| clean::key(&fingerprint, path, text))
1211    });
1212    if let Some(key) = &memo_key
1213        && let Some(decision) = clean::get_source(key)
1214    {
1215        if let Some(capture) = &capture {
1216            capture.record_as(&decision.decision, AdmissionCounts::default(), external);
1217        }
1218        return Ok(SourceAdmission {
1219            text: project_original
1220                .expect("memo key requires accepted project policy")
1221                .into_owned(),
1222            storable: decision.storable,
1223            classification: decision.classification,
1224        });
1225    }
1226
1227    let mut admission = admit(text, path.map(Path::new), class, &policy);
1228    mark_capture_incomplete(&mut admission, completeness, &policy);
1229    let storable = completeness.is_complete() && admission.storable();
1230    let gateway_admitted = admission.text.clone();
1231    let mut project_admitted = project_original
1232        .as_deref()
1233        .ok()
1234        .zip(gateway_admitted.as_deref())
1235        .and_then(|(project_original, gateway_admitted)| {
1236            compose_active_gateway_output(
1237                project_original,
1238                gateway_admitted,
1239                class,
1240                &policy,
1241                completeness,
1242            )
1243        });
1244    let mut refusal_reason = if project_original.is_err()
1245        || (gateway_admitted.is_some() && project_admitted.is_none())
1246    {
1247        Some("policy.content_withheld")
1248    } else {
1249        None
1250    };
1251
1252    if let Some(admitted) = &project_admitted {
1253        if project_rewrote
1254            || gateway_admitted
1255                .as_deref()
1256                .is_some_and(|gateway| gateway != admitted)
1257        {
1258            admission.text = Some(admitted.clone());
1259            mark_project_redaction(&mut admission);
1260        }
1261    }
1262    if preserve_bytes
1263        && let Some(gateway_admitted) = gateway_admitted.as_deref()
1264        && let Some(project_admitted_text) = project_admitted.as_deref()
1265        && preserve_bytes_rewrite_reason(
1266            text,
1267            gateway_admitted,
1268            project_admitted_text,
1269            project_rewrote,
1270        )
1271        .is_some()
1272    {
1273        project_admitted = None;
1274        refusal_reason = Some("source.bytes_not_preserved");
1275    }
1276    if let Some(reason_code) = refusal_reason {
1277        mark_policy_withheld(&mut admission, reason_code);
1278    }
1279    if let Some(capture) = &capture {
1280        capture.record_as(&admission.decision, admission.counts, external);
1281    }
1282
1283    let Some(project_admitted) = project_admitted else {
1284        return Err(admission.decision.reason_codes);
1285    };
1286
1287    if let Some(key) = memo_key
1288        && admission.disposition() == ContextDispositionV1::Allow
1289        && admission.decision.reason_codes.is_empty()
1290        && admission.text.as_deref() == Some(text)
1291        && project_admitted == text
1292    {
1293        clean::insert_source(
1294            key,
1295            admission.decision.clone(),
1296            admission.classification,
1297            admission.storable(),
1298        );
1299    }
1300    let redaction_counts = policy.gateway.hud.redaction_counts_in_band();
1301    if admission.hud_line_placed(redaction_counts).is_some() {
1302        notes::record(
1303            project_admitted.as_ref(),
1304            notes::Note {
1305                counts: admission.counts,
1306                decision: admission.decision.clone(),
1307                redaction_counts,
1308            },
1309        );
1310    }
1311    Ok(SourceAdmission {
1312        text: project_admitted,
1313        storable,
1314        classification: admission.classification,
1315    })
1316}
1317
1318fn preserve_bytes_rewrite_reason(
1319    source: &str,
1320    gateway_admitted: &str,
1321    project_admitted: &str,
1322    project_rewrote_original: bool,
1323) -> Option<&'static str> {
1324    if project_rewrote_original || project_admitted != gateway_admitted {
1325        Some("source content withheld: an active policy would rewrite a digest-bound source")
1326    } else if gateway_admitted != source {
1327        Some("source content withheld: the context gateway would rewrite a digest-bound source")
1328    } else {
1329        None
1330    }
1331}
1332
1333/// Admit an object no built-in detector can read (an image). Every enabled
1334/// detector reports `unsupported`; developer mode delivers it with a note,
1335/// governed and sovereign modes withhold it.
1336#[must_use]
1337pub fn admit_uninspectable(path: Option<&Path>, bytes: u64, policy: &AdmissionPolicy) -> Admission {
1338    let gateway = &policy.gateway;
1339    let object = content_digest(&format!("uninspectable:{bytes}"));
1340    let actions = policy.actions();
1341    let signals = detectors::unsupported_signals(&actions, bytes);
1342    let required = signals.iter().any(|signal| {
1343        signal.coverage.kind == lean_ctx_protocol::context_gateway::CoverageKindV1::Unsupported
1344    });
1345    if policy.config_unloadable() || !policy.gateway_switch() || !required {
1346        return admit("", path, &origin::SourceClass::Project, policy);
1347    }
1348    let restricted_path =
1349        path.is_some_and(|p| crate::core::io_boundary::is_secret_like(p).is_some());
1350    let mandatory = policy.detectors_mandatory();
1351    Admission {
1352        text: (!mandatory).then(String::new),
1353        classification: if restricted_path {
1354            ClassificationV1::Restricted
1355        } else {
1356            gateway.mode.unclassified()
1357        },
1358        trust: TrustLevelV1::Unknown,
1359        decision: ContextDecisionV1 {
1360            object,
1361            disposition: if mandatory {
1362                ContextDispositionV1::Deny
1363            } else {
1364                ContextDispositionV1::Allow
1365            },
1366            reason_codes: vec![reason("coverage.unsupported_media")],
1367            signals,
1368            required_transformations: Vec::new(),
1369        },
1370        counts: AdmissionCounts {
1371            incomplete: 1,
1372            ..AdmissionCounts::default()
1373        },
1374    }
1375}
1376
1377/// Media gate for the read path: `Ok(note)` to deliver with a visible note,
1378/// or a content-free `PermissionDenied` when the media must be withheld.
1379pub fn admit_media(path: &str, bytes: u64) -> Result<Option<String>, std::io::Error> {
1380    let policy = carrier::current_policy();
1381    let admission = admit_uninspectable(Some(Path::new(path)), bytes, &policy);
1382    if let Some(capture) = capture::current() {
1383        capture.bind_policy(
1384            policy.gateway.mode,
1385            fingerprint_digest(&policy.fingerprint()),
1386        );
1387        capture.record(&admission.decision, admission.counts);
1388    }
1389    if admission.text.is_none() {
1390        return Err(std::io::Error::new(
1391            std::io::ErrorKind::PermissionDenied,
1392            "media withheld by the context gateway (coverage.unsupported_media)",
1393        ));
1394    }
1395    Ok((admission.counts.incomplete > 0)
1396        .then(|| "[lean-ctx gateway: media not inspected (coverage.unsupported_media)]".to_owned()))
1397}
1398
1399/// Forget every memoized clean admission (measurements of cold reads).
1400#[cfg(any(test, feature = "test-support"))]
1401pub fn clear_clean_memo() {
1402    clean::clear();
1403}
1404
1405/// The HUD line recorded when exactly `admitted` was produced, stated for
1406/// `delivered`: the part of `admitted` a tool actually returns (a line
1407/// window, a compressed view). Masked values are counted from the markers
1408/// `delivered` carries, never above the object's own counts, so a window
1409/// without a masked value gets no redaction count — and no line when nothing
1410/// else applies (#2135). Every other part of the note describes the object.
1411#[must_use]
1412pub fn note_for(admitted: &str, delivered: &str) -> Option<String> {
1413    let note = notes::lookup(admitted)?;
1414    let mut counts = note.counts;
1415    if note.decision.disposition == ContextDispositionV1::AllowRedacted {
1416        let pii = crate::core::input_filters::pii::checksummed_marker_count(delivered);
1417        let other = delivered
1418            .matches("[REDACTED:")
1419            .count()
1420            .saturating_sub(pii + delivered.matches(INJECTION_MARKER).count());
1421        counts.pii = counts.pii.min(saturating_u32(pii));
1422        counts.secrets = counts.secrets.min(saturating_u32(other));
1423    }
1424    render_hud(counts, &note.decision, false, note.redaction_counts)
1425}
1426
1427const INJECTION_MARKER: &str = "[REDACTED:prompt-injection]";
1428
1429fn saturating_u32(n: usize) -> u32 {
1430    u32::try_from(n).unwrap_or(u32::MAX)
1431}
1432
1433fn reason(code: &str) -> ReasonCodeV1 {
1434    ReasonCodeV1::new(code).expect("built-in reason codes are valid")
1435}
1436
1437/// Record in the running call's receipt that a derived copy admitted before
1438/// (at its cache fill) was asked of its source again at this serve and
1439/// refused: the after side of the revocation boundary (C04). The decision is
1440/// a denial whose reason is the source state (`source.revoked`, …); its
1441/// object is one fixed digest for every such refusal, so the receipt names
1442/// neither the item nor its text and reads the same for every item withheld
1443/// this way (K02/S02). No capture: nothing is recorded.
1444pub(crate) fn record_recheck_refusal(
1445    admission: &stores::StoreAdmission,
1446    validity: lean_ctx_protocol::context_lineage::SourceValidityV1,
1447) {
1448    let Some(capture) = capture::current() else {
1449        return;
1450    };
1451    let policy = admission.admission_policy();
1452    capture.bind_policy(
1453        policy.gateway.mode,
1454        fingerprint_digest(&policy.fingerprint()),
1455    );
1456    let decision = ContextDecisionV1 {
1457        object: fingerprint_digest(b"source-recheck-refused"),
1458        disposition: ContextDispositionV1::Deny,
1459        reason_codes: vec![reason(validity.reason_code())],
1460        signals: Vec::new(),
1461        required_transformations: Vec::new(),
1462    };
1463    capture.record(&decision, AdmissionCounts::default());
1464}
1465
1466/// SHA-256 of the canonical policy fingerprint: the receipt's policy digest.
1467fn fingerprint_digest(fingerprint: &[u8]) -> Sha256Digest {
1468    let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(fingerprint));
1469    Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
1470}
1471
1472fn content_digest(text: &str) -> Sha256Digest {
1473    let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(text.as_bytes()));
1474    Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
1475}
1476
1477#[cfg(test)]
1478mod shell_admission_regression_tests;
1479
1480#[cfg(test)]
1481mod tests;
1482
1483#[cfg(test)]
1484mod operand_tests;
1485
1486#[cfg(test)]
1487mod receipt_tests;
1488
1489#[cfg(test)]
1490mod store_tests;
1491
1492#[cfg(test)]
1493mod egress_tests;
1494
1495#[cfg(test)]
1496mod egress_api_tests;