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 provider;
27pub mod receipt_store;
28pub mod recovery;
29pub(crate) mod semantic;
30pub mod semantic_deferred;
31pub mod stored;
32pub mod stores;
33pub mod withheld;
34
35use std::path::Path;
36
37use lean_ctx_protocol::Sha256Digest;
38use lean_ctx_protocol::context_gateway::{
39    ClassificationV1, ContextDecisionV1, ContextDispositionV1, CoverageKindV1,
40    DestinationConstraintV1, DestinationLocalityV1, DestinationV1, DetectorSignalV1, GatewayModeV1,
41    ReasonCodeV1, TransformationKindV1, TrustLevelV1,
42};
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45
46use crate::core::config::SecretDetectionConfig;
47use crate::core::input_filters::FilterAction;
48
49/// `[context_gateway]` — global only; never merged from a project-local file.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(default)]
52pub struct ContextGatewayConfig {
53    /// Master switch. `LEAN_CTX_CONTEXT_GATEWAY=off` overrides it.
54    pub enabled: bool,
55    pub mode: GatewayModeV1,
56    /// Credentials and keys (patterns from `[secret_detection]`).
57    pub secrets: FilterAction,
58    /// Checksum-validated PII only: payment cards.
59    pub pii: FilterAction,
60    /// Prompt-injection heuristic (OWASP LLM01).
61    pub injection: FilterAction,
62    /// Explicit classification markings (`CONFIDENTIAL`, `classification: …`).
63    pub classification: FilterAction,
64    /// Bytes each detector may inspect per object; beyond it, coverage is
65    /// `partial`. Governed and sovereign modes withhold partially inspected
66    /// content, developer mode delivers it with a visible note.
67    pub max_inspected_bytes: usize,
68    /// Per-detector time budget; a detector that runs out reports `timed_out`.
69    pub detector_timeout_ms: u64,
70    /// Additional placement constraints applied to every final egress target.
71    /// These remain active when content scanning is disabled.
72    pub destination_constraints: Vec<DestinationConstraintV1>,
73    /// Optional target allow-list. Provider and locality are always checked;
74    /// model/account/region are checked when the configured entry supplies them.
75    /// Unknown locality never matches. Account references are configured labels,
76    /// not provider-verified identities.
77    pub allowed_destinations: Vec<DestinationV1>,
78    /// Where redaction counts are shown (see `hud`).
79    pub hud: hud::HudPlacement,
80}
81
82/// Same bound as policy-pack content inspection (`policy::content`).
83pub const DEFAULT_MAX_INSPECTED_BYTES: usize = 8 * 1024 * 1024;
84pub const DEFAULT_DETECTOR_TIMEOUT_MS: u64 = 2_000;
85
86impl Default for ContextGatewayConfig {
87    fn default() -> Self {
88        Self {
89            enabled: true,
90            mode: GatewayModeV1::Developer,
91            secrets: FilterAction::Redact,
92            pii: FilterAction::Redact,
93            injection: FilterAction::Warn,
94            classification: FilterAction::Warn,
95            max_inspected_bytes: DEFAULT_MAX_INSPECTED_BYTES,
96            detector_timeout_ms: DEFAULT_DETECTOR_TIMEOUT_MS,
97            destination_constraints: Vec::new(),
98            allowed_destinations: Vec::new(),
99            hud: hud::HudPlacement::Auto,
100        }
101    }
102}
103
104impl ContextGatewayConfig {
105    /// Effective master switch. The `LEAN_CTX_CONTEXT_GATEWAY` kill switch
106    /// (`0|false|off`) is a developer convenience: it disables admission only
107    /// in developer mode. A governed or sovereign deployment is configured by
108    /// its operator in the global file; a process environment cannot switch
109    /// its protection off, so the variable is ignored there (and reported, see
110    /// [`Self::kill_switch`]). Any other value still forces the gateway on.
111    #[must_use]
112    pub fn enabled_effective(&self) -> bool {
113        self.enabled_under(EnvSwitch::read())
114    }
115
116    /// The effective switch under one captured environment state. Admission
117    /// uses only this form, with the state its `AdmissionPolicy` captured:
118    /// the live environment is read once per snapshot, never per decision.
119    #[must_use]
120    pub(crate) fn enabled_under(&self, env: EnvSwitch) -> bool {
121        match self.kill_switch_under(env) {
122            KillSwitch::Unset | KillSwitch::Ignored => self.enabled,
123            KillSwitch::ForcedOn => true,
124            KillSwitch::Honoured => false,
125        }
126    }
127
128    /// What the `LEAN_CTX_CONTEXT_GATEWAY` environment variable does under
129    /// this configuration (for `doctor` and the admission log). Reads the
130    /// live environment.
131    #[must_use]
132    pub fn kill_switch(&self) -> KillSwitch {
133        self.kill_switch_under(EnvSwitch::read())
134    }
135
136    fn kill_switch_under(&self, env: EnvSwitch) -> KillSwitch {
137        match env {
138            EnvSwitch::Unset => return KillSwitch::Unset,
139            EnvSwitch::On => return KillSwitch::ForcedOn,
140            EnvSwitch::Off => {}
141        }
142        if self.mode == GatewayModeV1::Developer {
143            KillSwitch::Honoured
144        } else {
145            static REPORTED: std::sync::Once = std::sync::Once::new();
146            REPORTED.call_once(|| {
147                tracing::warn!(
148                    "LEAN_CTX_CONTEXT_GATEWAY=off ignored: the context gateway runs in {:?} mode",
149                    self.mode
150                );
151            });
152            KillSwitch::Ignored
153        }
154    }
155
156    /// Destination policy is a hard authorization gate, independent of the
157    /// optional content-detector switch.
158    #[must_use]
159    pub(crate) fn has_destination_policy(&self) -> bool {
160        !self.destination_constraints.is_empty() || !self.allowed_destinations.is_empty()
161    }
162
163    /// Check the final, trusted destination against configured placement rules.
164    #[must_use]
165    pub(crate) fn allows_destination(&self, actual: &DestinationV1) -> bool {
166        if actual.locality == DestinationLocalityV1::Unknown
167            || !actual.satisfies_all(&self.destination_constraints)
168        {
169            return false;
170        }
171        self.allowed_destinations.is_empty()
172            || self.allowed_destinations.iter().any(|expected| {
173                expected.locality != DestinationLocalityV1::Unknown
174                    && expected.provider == actual.provider
175                    && expected.locality == actual.locality
176                    && expected.organization_managed == actual.organization_managed
177                    && expected
178                        .model
179                        .as_ref()
180                        .is_none_or(|reference| actual.model.as_ref() == Some(reference))
181                    && expected
182                        .account_ref
183                        .as_ref()
184                        .is_none_or(|reference| actual.account_ref.as_ref() == Some(reference))
185                    && expected
186                        .region
187                        .as_ref()
188                        .is_none_or(|reference| actual.region.as_ref() == Some(reference))
189            })
190    }
191}
192
193/// The raw state of `LEAN_CTX_CONTEXT_GATEWAY`, read once and carried in the
194/// admission snapshot. It is the only environment-derived input of admission.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub(crate) enum EnvSwitch {
197    Unset,
198    /// `0|false|off`.
199    Off,
200    /// Any other value.
201    On,
202}
203
204impl EnvSwitch {
205    /// The live environment, now.
206    pub(crate) fn read() -> Self {
207        match std::env::var("LEAN_CTX_CONTEXT_GATEWAY") {
208            Err(_) => Self::Unset,
209            Ok(value) if matches!(value.trim(), "0" | "false" | "off") => Self::Off,
210            Ok(_) => Self::On,
211        }
212    }
213}
214
215/// Effect of the `LEAN_CTX_CONTEXT_GATEWAY` environment variable.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum KillSwitch {
218    Unset,
219    /// Set to a value other than `0|false|off`: the gateway is on.
220    ForcedOn,
221    /// `off` in developer mode: admission is disabled (`gateway.disabled`).
222    Honoured,
223    /// `off` in governed or sovereign mode: has no effect.
224    Ignored,
225}
226
227/// Everything `admit` needs, resolved once from the global configuration.
228#[derive(Debug, Clone)]
229pub struct AdmissionPolicy {
230    pub gateway: ContextGatewayConfig,
231    /// Pattern source for the secrets detector. An explicit
232    /// `secret_detection.enabled = false` turns the secrets detector off.
233    pub secret_patterns: SecretDetectionConfig,
234    /// Optional licensed semantic detectors (advisory; see `semantic`).
235    pub(crate) semantic: Option<semantic::SemanticDetector>,
236    /// Set when the snapshot this policy was resolved from could not apply the
237    /// global configuration, so `gateway` holds built-in defaults, not the
238    /// operator's settings. The configured mode is unknown: nothing is
239    /// admitted in this state, in any mode.
240    load_failure: Option<crate::core::config::ConfigLoadFailure>,
241    /// The environment switch as it was when this snapshot was taken.
242    env_switch: EnvSwitch,
243}
244
245impl AdmissionPolicy {
246    /// Resolve from a configuration snapshot. Whether the operator's settings
247    /// were applied is read from that same snapshot (`Config::load_failure`),
248    /// never from the process-global parse diagnostic.
249    #[must_use]
250    pub fn from_config(config: &crate::core::config::Config) -> Self {
251        Self {
252            gateway: config.context_gateway.clone(),
253            secret_patterns: config.secret_detection.clone(),
254            semantic: semantic::SemanticDetector::for_snapshot(config),
255            load_failure: config.load_failure,
256            env_switch: EnvSwitch::read(),
257        }
258    }
259
260    /// Built-in detectors only, from explicit settings.
261    #[must_use]
262    pub fn builtin(gateway: ContextGatewayConfig, secret_patterns: SecretDetectionConfig) -> Self {
263        Self {
264            gateway,
265            secret_patterns,
266            semantic: None,
267            load_failure: None,
268            env_switch: EnvSwitch::read(),
269        }
270    }
271
272    /// Whether this policy stands in for a global configuration that could
273    /// not be loaded (see [`Self::from_config`]).
274    #[must_use]
275    pub fn config_unloadable(&self) -> bool {
276        self.load_failure.is_some()
277    }
278
279    /// Reason codes of the refusal for an unloadable configuration: the one
280    /// stable admission code, then the loader's specific code. Never the
281    /// parse error text, which can quote file content.
282    pub(crate) fn config_refusal_codes(&self) -> Option<[&'static str; 2]> {
283        self.load_failure
284            .map(|failure| ["gateway.config_unloadable", failure.code()])
285    }
286
287    /// The master switch as admission applies it: an unloadable configuration
288    /// can never be "disabled", whatever its default values or the
289    /// environment say.
290    #[must_use]
291    pub fn enabled_effective(&self) -> bool {
292        self.config_unloadable() || self.gateway_switch()
293    }
294
295    /// The gateway's own switch under the environment state this snapshot
296    /// captured when it was built. The fingerprint and every decision taken
297    /// with this policy use exactly this value; the live environment is not
298    /// consulted again. Whether the snapshot is still current is a separate,
299    /// explicit question (`egress::PolicyEpoch::is_current`).
300    fn gateway_switch(&self) -> bool {
301        self.gateway.enabled_under(self.env_switch)
302    }
303
304    fn secrets_action(&self) -> FilterAction {
305        if self.secret_patterns.enabled {
306            self.gateway.secrets
307        } else {
308            FilterAction::Off
309        }
310    }
311
312    fn actions(&self) -> detectors::Actions {
313        detectors::Actions {
314            secrets: self.secrets_action(),
315            pii: self.gateway.pii,
316            injection: self.gateway.injection,
317            classification: self.gateway.classification,
318        }
319    }
320
321    fn budget(&self) -> detectors::Budget {
322        detectors::Budget {
323            max_bytes: self.gateway.max_inspected_bytes,
324            deadline: std::time::Duration::from_millis(self.gateway.detector_timeout_ms),
325        }
326    }
327
328    /// Canonical bytes of everything that decides an admission: the gateway
329    /// settings and the secret patterns. Keys the clean memo and is hashed into
330    /// every receipt's policy reference.
331    fn fingerprint(&self) -> Vec<u8> {
332        let mut value = serde_json::json!({
333            "gateway": &self.gateway,
334            "secret_patterns": &self.secret_patterns,
335            // `false` without a detector (bytes unchanged); otherwise the
336            // identity of the runtime this policy's detector is bound to.
337            "semantic": self
338                .semantic
339                .as_ref()
340                .map_or(serde_json::Value::Bool(false), semantic::SemanticDetector::identity),
341        });
342        // Added only in the failure state, so every digest recorded under a
343        // loadable configuration keeps its bytes.
344        if let Some(failure) = self.load_failure
345            && let Some(map) = value.as_object_mut()
346        {
347            map.insert("config_unloadable".to_owned(), failure.code().into());
348        }
349        // The effective switch is an input of every decision, and the
350        // environment can move it without touching a configuration byte
351        // (`LEAN_CTX_CONTEXT_GATEWAY`). It is bound whenever it differs from
352        // the configured value — and only then, so digests taken without an
353        // environment override keep their bytes.
354        let effective = self.enabled_effective();
355        if effective != self.gateway.enabled
356            && let Some(map) = value.as_object_mut()
357        {
358            map.insert("enabled_effective".to_owned(), effective.into());
359        }
360        serde_json::to_vec(&value).unwrap_or_default()
361    }
362
363    /// Governed and sovereign modes treat every enabled detector as mandatory:
364    /// content it could not fully inspect is withheld.
365    fn detectors_mandatory(&self) -> bool {
366        self.gateway.mode != GatewayModeV1::Developer
367    }
368}
369
370/// Content-free counts for the HUD line and the receipt.
371#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
372pub struct AdmissionCounts {
373    pub secrets: u32,
374    pub pii: u32,
375    /// 1 when the object carries prompt-injection signals: an object is
376    /// flagged once, however many of its lines matched.
377    pub injection: u32,
378    /// Which pattern families flagged it
379    /// ([`crate::core::output_sanitizer::InjectionCategory`] bits).
380    pub injection_patterns: u8,
381    pub markings: u32,
382    /// Enabled detectors that did not inspect the whole object.
383    pub incomplete: u32,
384    /// Findings of the optional semantic detectors.
385    pub semantic: u32,
386}
387
388/// The outcome of admitting one object.
389#[derive(Debug, Clone)]
390pub struct Admission {
391    /// `None` when the content is withheld; never the original then.
392    pub text: Option<String>,
393    pub classification: ClassificationV1,
394    pub trust: TrustLevelV1,
395    pub decision: ContextDecisionV1,
396    pub counts: AdmissionCounts,
397}
398
399impl Admission {
400    #[must_use]
401    pub fn disposition(&self) -> ContextDispositionV1 {
402        self.decision.disposition
403    }
404
405    /// Whether the delivered text may enter derived stores (caches, indexes,
406    /// archives). Restricted content never does (owner decision E3).
407    #[must_use]
408    pub fn storable(&self) -> bool {
409        self.text.is_some() && self.classification < ClassificationV1::Restricted
410    }
411
412    /// One stable, content-free line describing what the gateway did, or
413    /// `None` when it changed and flagged nothing.
414    #[must_use]
415    pub fn hud_line(&self) -> Option<String> {
416        self.hud_line_placed(true)
417    }
418
419    /// [`Self::hud_line`] for the model-facing output. With
420    /// `redaction_counts == false` (the host shows lean-ctx out of band),
421    /// counts of masked values are left to the status line: the markers in the
422    /// content already tell the model, the human sees the count elsewhere.
423    /// Detected-but-delivered values, untrusted content and incomplete
424    /// inspection are always stated, because the model must act on them.
425    #[must_use]
426    pub fn hud_line_placed(&self, redaction_counts: bool) -> Option<String> {
427        render_hud(
428            self.counts,
429            &self.decision,
430            self.text.is_none(),
431            redaction_counts,
432        )
433    }
434}
435
436/// The HUD line for `counts` under `decision`; see [`Admission::hud_line_placed`].
437fn render_hud(
438    c: AdmissionCounts,
439    decision: &ContextDecisionV1,
440    withheld: bool,
441    redaction_counts: bool,
442) -> Option<String> {
443    let mut parts = Vec::new();
444    let redacted = decision.disposition == ContextDispositionV1::AllowRedacted;
445    let verb = if redacted { "redacted" } else { "detected" };
446    let show_values = !redacted || redaction_counts;
447    if c.secrets > 0 && show_values {
448        parts.push(format!("{} secret(s) {verb}", c.secrets));
449    }
450    if c.pii > 0 && show_values {
451        parts.push(format!("{} PII value(s) {verb}", c.pii));
452    }
453    if c.injection > 0 {
454        parts.push(format!("{} prompt-injection signal(s)", c.injection));
455    }
456    if c.markings > 0 {
457        parts.push(format!("{} classification marking(s)", c.markings));
458    }
459    let codes = || decision.reason_codes.iter().map(ReasonCodeV1::as_str);
460    if c.incomplete > 0 {
461        let reasons: Vec<&str> = codes()
462            .filter(|code| code.starts_with("coverage.") || code.starts_with("detector."))
463            .collect();
464        parts.push(format!("not fully inspected ({})", reasons.join(", ")));
465    }
466    if c.semantic > 0 {
467        parts.push(format!("{} semantic finding(s)", c.semantic));
468    }
469    let semantic_gaps: Vec<&str> = codes()
470        .filter(|code| {
471            matches!(*code, "semantic.partial" | "semantic.unavailable")
472                || (code.starts_with("semantic.") && code.ends_with(".not_inspected"))
473        })
474        .collect();
475    if !semantic_gaps.is_empty() {
476        parts.push(format!(
477            "semantic check incomplete ({})",
478            semantic_gaps.join(", ")
479        ));
480    }
481    if withheld {
482        let reasons: Vec<&str> = codes().collect();
483        return Some(format!(
484            "[lean-ctx gateway: content withheld — {}]",
485            reasons.join(", ")
486        ));
487    }
488    (!parts.is_empty()).then(|| format!("[lean-ctx gateway: {}]", parts.join(" · ")))
489}
490
491/// Admit `text` (from `path`, when it came from a file) under `policy`.
492#[must_use]
493pub fn admit(text: &str, path: Option<&Path>, policy: &AdmissionPolicy) -> Admission {
494    let object = content_digest(text);
495    let gateway = &policy.gateway;
496    if let Some(codes) = policy.config_refusal_codes() {
497        // The operator's mode and detector actions are unknown. Running the
498        // built-in defaults here would silently turn a governed installation
499        // into a developer one.
500        return Admission {
501            text: None,
502            classification: gateway.mode.unclassified(),
503            trust: TrustLevelV1::Unknown,
504            decision: ContextDecisionV1 {
505                object,
506                disposition: ContextDispositionV1::Deny,
507                reason_codes: codes.iter().map(|code| reason(code)).collect(),
508                signals: Vec::new(),
509                required_transformations: Vec::new(),
510            },
511            counts: AdmissionCounts::default(),
512        };
513    }
514    if !policy.gateway_switch() {
515        return Admission {
516            text: Some(text.to_owned()),
517            classification: gateway.mode.unclassified(),
518            trust: TrustLevelV1::Unknown,
519            decision: ContextDecisionV1 {
520                object,
521                disposition: ContextDispositionV1::Allow,
522                reason_codes: vec![reason("gateway.disabled")],
523                signals: Vec::new(),
524                required_transformations: Vec::new(),
525            },
526            counts: AdmissionCounts::default(),
527        };
528    }
529
530    let actions = policy.actions();
531    let findings = detectors::inspect(
532        text,
533        path,
534        &actions,
535        &policy.secret_patterns,
536        policy.budget(),
537    );
538    let mut counts = findings.counts();
539
540    let mut reasons = Vec::new();
541    let mut disposition = ContextDispositionV1::Allow;
542    // A detector that did not inspect everything never reports "clean": its
543    // reason is always recorded, and mandatory detectors withhold the object.
544    for incomplete in findings.incomplete() {
545        reasons.push(incomplete);
546        if policy.detectors_mandatory() {
547            disposition = ContextDispositionV1::Deny;
548        }
549    }
550    let mut transformations = Vec::new();
551    for (action, hits, code) in [
552        (actions.secrets, counts.secrets, "secret"),
553        (actions.pii, counts.pii, "pii"),
554        (actions.injection, counts.injection, "injection"),
555        (actions.classification, counts.markings, "classification"),
556    ] {
557        if hits == 0 {
558            continue;
559        }
560        let step = match action {
561            FilterAction::Block => {
562                reasons.push(reason(&format!("{code}.blocked")));
563                ContextDispositionV1::Deny
564            }
565            // A marking classifies the whole object; there is no span to mask.
566            FilterAction::Redact if code == "classification" => {
567                reasons.push(reason(&format!("{code}.blocked")));
568                ContextDispositionV1::Deny
569            }
570            FilterAction::Redact => {
571                reasons.push(reason(&format!("{code}.redacted")));
572                if !transformations.contains(&TransformationKindV1::Redaction) {
573                    transformations.push(TransformationKindV1::Redaction);
574                }
575                ContextDispositionV1::AllowRedacted
576            }
577            FilterAction::Warn => {
578                reasons.push(reason(&format!("{code}.flagged")));
579                ContextDispositionV1::Allow
580            }
581            FilterAction::Off => continue,
582        };
583        disposition = disposition.most_restrictive(step);
584    }
585
586    // Optional semantic detectors: advisory, so an incomplete run is reported
587    // but only an actual finding can tighten the decision.
588    let semantic = policy
589        .semantic
590        .as_ref()
591        .map(|detect| semantic::run(text, detect, &actions, gateway.max_inspected_bytes));
592    if let Some(outcome) = &semantic {
593        counts.semantic = outcome.hits;
594        for (step, code) in &outcome.steps {
595            reasons.push(code.clone());
596            if *step == ContextDispositionV1::AllowRedacted
597                && !transformations.contains(&TransformationKindV1::Redaction)
598            {
599                transformations.push(TransformationKindV1::Redaction);
600            }
601            disposition = disposition.most_restrictive(*step);
602        }
603        if let Some(code) = &outcome.incomplete {
604            reasons.push(code.clone());
605        }
606    }
607
608    let mut delivered = if disposition == ContextDispositionV1::Deny {
609        None
610    } else {
611        // Semantic spans refer to the original text, so they are masked first.
612        let semantic_masked = semantic
613            .as_ref()
614            .filter(|outcome| !outcome.redactions.is_empty())
615            .map(|outcome| semantic::redact(text, &outcome.redactions));
616        let source = semantic_masked.as_deref().unwrap_or(text);
617        Some(findings.redact(source, &actions, &policy.secret_patterns))
618    };
619    // Fail closed: a redaction that leaves a detectable secret or PII value
620    // behind must not be delivered as if it were clean. Unchanged text was
621    // already inspected by the same detectors, so only a rewrite is re-checked.
622    if let Some(output) = &delivered
623        && output != text
624        && detectors::residual(output, &actions, &policy.secret_patterns)
625    {
626        reasons.push(reason("redaction.incomplete"));
627        disposition = ContextDispositionV1::Deny;
628        delivered = None;
629    }
630
631    let classification = findings.classification(gateway.mode, &actions);
632    let semantic_injection = semantic.as_ref().is_some_and(|outcome| {
633        outcome
634            .steps
635            .iter()
636            // A finding, not the statement that the category was not inspected.
637            .any(|(_, code)| {
638                let code = code.as_str();
639                code.starts_with("semantic.prompt_injection") && !code.ends_with(".not_inspected")
640            })
641    });
642    let trust = if counts.injection > 0 || semantic_injection {
643        TrustLevelV1::Untrusted
644    } else {
645        TrustLevelV1::Internal
646    };
647    let mut signals: Vec<DetectorSignalV1> = findings.signals();
648    if let Some(outcome) = semantic {
649        signals.push(outcome.signal);
650    }
651    Admission {
652        text: delivered,
653        classification,
654        trust,
655        decision: ContextDecisionV1 {
656            object,
657            disposition,
658            reason_codes: reasons,
659            signals,
660            required_transformations: transformations,
661        },
662        counts,
663    }
664}
665
666/// Capture facts from a shell process. The flags describe raw byte streams,
667/// before decoding or rendering; marker text is never used to infer completeness.
668#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
669pub struct OutputCompleteness {
670    pub truncated: bool,
671    pub timed_out: bool,
672    pub reader_incomplete: bool,
673    pub cancelled: bool,
674    /// Set conservatively when captured bytes required lossy text decoding.
675    /// On Windows the legacy ACP decoder may also be lossy for some code pages.
676    pub decode_lossy: bool,
677}
678
679impl OutputCompleteness {
680    pub const fn complete() -> Self {
681        Self {
682            truncated: false,
683            timed_out: false,
684            reader_incomplete: false,
685            cancelled: false,
686            decode_lossy: false,
687        }
688    }
689
690    pub fn is_complete(self) -> bool {
691        !(self.truncated
692            || self.timed_out
693            || self.reader_incomplete
694            || self.cancelled
695            || self.decode_lossy)
696    }
697
698    fn reason_codes(self) -> Vec<&'static str> {
699        let mut reasons = Vec::new();
700        if self.truncated {
701            reasons.push("coverage.output_truncated");
702        }
703        if self.timed_out {
704            reasons.push("coverage.output_timed_out");
705        }
706        if self.reader_incomplete {
707            reasons.push("coverage.output_reader_incomplete");
708        }
709        if self.cancelled {
710            reasons.push("coverage.output_cancelled");
711        }
712        if self.decode_lossy {
713            reasons.push("coverage.output_decode_lossy");
714        }
715        reasons
716    }
717}
718
719/// Result of inspecting tool output. A missing text means delivery is denied;
720/// rendered then contains only the stable, content-free gateway notice.
721#[derive(Debug, Clone)]
722pub struct ToolOutputAdmission {
723    pub text: Option<String>,
724    pub notice: Option<String>,
725}
726
727impl ToolOutputAdmission {
728    pub fn withheld(&self) -> bool {
729        self.text.is_none()
730    }
731
732    pub fn rendered(&self) -> String {
733        match (&self.text, &self.notice) {
734            (Some(text), Some(notice)) if text.is_empty() => notice.clone(),
735            (Some(text), Some(notice)) => format!("{notice}\n{text}"),
736            (Some(text), None) => text.clone(),
737            (None, Some(notice)) => notice.clone(),
738            (None, None) => "[lean-ctx gateway: content withheld]".to_string(),
739        }
740    }
741}
742
743pub fn admit_tool_output(text: &str, completeness: OutputCompleteness) -> ToolOutputAdmission {
744    // Check active project policy against the original bytes before Gateway
745    // redaction can hide a phrase that policy must block.
746    let project_original = crate::core::policy::content::protect_active(text);
747    let project_rewrote = project_original
748        .as_ref()
749        .is_ok_and(|admitted| admitted.as_ref() != text);
750    let policy = carrier::current_policy();
751    let mut admission = admit(text, None, &policy);
752    mark_capture_incomplete(&mut admission, completeness, &policy);
753
754    let gateway_output = admission.text.clone();
755    let text = project_original
756        .as_deref()
757        .ok()
758        .zip(gateway_output.as_deref())
759        .and_then(|(project_original, gateway_output)| {
760            compose_active_gateway_output(project_original, gateway_output, &policy, completeness)
761        });
762    let policy_withheld = project_original.is_err() || (gateway_output.is_some() && text.is_none());
763    if let Some(admitted) = &text {
764        admission.text = Some(admitted.clone());
765        if project_rewrote
766            || gateway_output
767                .as_deref()
768                .is_some_and(|gateway| gateway != admitted)
769        {
770            mark_project_redaction(&mut admission);
771        }
772    } else if policy_withheld {
773        mark_policy_withheld(&mut admission, "policy.content_withheld");
774    }
775
776    let capture = capture::current();
777    if let Some(capture) = &capture {
778        capture.bind_policy(
779            policy.gateway.mode,
780            fingerprint_digest(&policy.fingerprint()),
781        );
782        capture.record(&admission.decision, admission.counts);
783    }
784    let mut notice = admission.hud_line_placed(policy.gateway.hud.redaction_counts_in_band());
785    if policy_withheld {
786        notice.get_or_insert_with(|| "[lean-ctx policy: content withheld]".to_string());
787    }
788    ToolOutputAdmission { text, notice }
789}
790
791fn mark_project_redaction(admission: &mut Admission) {
792    admission.decision.disposition = admission
793        .decision
794        .disposition
795        .most_restrictive(ContextDispositionV1::AllowRedacted);
796    let reason = reason("policy.content_redacted");
797    if !admission.decision.reason_codes.contains(&reason) {
798        admission.decision.reason_codes.push(reason);
799    }
800    if !admission
801        .decision
802        .required_transformations
803        .contains(&TransformationKindV1::Redaction)
804    {
805        admission
806            .decision
807            .required_transformations
808            .push(TransformationKindV1::Redaction);
809    }
810}
811
812fn mark_policy_withheld(admission: &mut Admission, reason_code: &str) {
813    admission.text = None;
814    admission.decision.disposition = ContextDispositionV1::Deny;
815    let reason = reason(reason_code);
816    if !admission.decision.reason_codes.contains(&reason) {
817        admission.decision.reason_codes.push(reason);
818    }
819}
820
821/// Compose the project-policy and Gateway transformations without losing either
822/// detector's view of the original input. If their order changes the result,
823/// withholding is safer than publishing text whose provenance is ambiguous.
824pub(crate) fn compose_active_gateway_output(
825    project_original: &str,
826    gateway_output: &str,
827    policy: &AdmissionPolicy,
828    completeness: OutputCompleteness,
829) -> Option<String> {
830    let project_after_gateway = crate::core::policy::content::protect_active(gateway_output)
831        .ok()?
832        .into_owned();
833    let mut gateway_after_project = admit(project_original, None, policy);
834    mark_capture_incomplete(&mut gateway_after_project, completeness, policy);
835    let gateway_after_project = gateway_after_project.text?;
836    (project_after_gateway == gateway_after_project).then_some(project_after_gateway)
837}
838
839pub(super) fn admit_output_for_storage(
840    text: &str,
841    completeness: OutputCompleteness,
842    policy: &AdmissionPolicy,
843) -> Option<String> {
844    // Incomplete process output is never cached or retained for later polls,
845    // even in Developer mode where the originating call may see a warning.
846    if !completeness.is_complete() {
847        return None;
848    }
849    let project_original = crate::core::policy::content::protect_active(text).ok()?;
850    let mut admission = admit(text, None, policy);
851    mark_capture_incomplete(&mut admission, completeness, policy);
852    if !admission.storable() {
853        return None;
854    }
855    let gateway_output = admission.text?;
856    compose_active_gateway_output(
857        project_original.as_ref(),
858        &gateway_output,
859        policy,
860        completeness,
861    )
862}
863
864/// A finished background job's output, kept until a status poll delivers it
865/// (#2164). Only a held pipe (a process the command left running) is
866/// tolerated; deliveries still pass `admit_tool_output` with the real flags,
867/// and truncated, timed-out, cancelled or lossy captures stay unretained.
868pub(super) fn admit_output_for_poll(
869    text: &str,
870    completeness: OutputCompleteness,
871    policy: &AdmissionPolicy,
872) -> Option<String> {
873    let without_reader = OutputCompleteness {
874        reader_incomplete: false,
875        ..completeness
876    };
877    if !completeness.reader_incomplete {
878        return admit_output_for_storage(text, completeness, policy);
879    }
880    if !without_reader.is_complete() || (policy.enabled_effective() && policy.detectors_mandatory())
881    {
882        return None;
883    }
884    admit_output_for_storage(text, without_reader, policy)
885}
886
887fn mark_capture_incomplete(
888    admission: &mut Admission,
889    completeness: OutputCompleteness,
890    policy: &AdmissionPolicy,
891) {
892    if completeness.is_complete() || !policy.enabled_effective() {
893        return;
894    }
895    for code in completeness.reason_codes() {
896        let reason = reason(code);
897        if !admission.decision.reason_codes.contains(&reason) {
898            admission.decision.reason_codes.push(reason);
899        }
900    }
901    let partial_reason = reason("coverage.output_partial");
902    if !admission.decision.reason_codes.contains(&partial_reason) {
903        admission.decision.reason_codes.push(partial_reason.clone());
904    }
905    for signal in &mut admission.decision.signals {
906        if signal.coverage.kind != CoverageKindV1::Complete {
907            continue;
908        }
909        // The process capture knows only the prefix length. One unknown byte is
910        // the protocol-valid lower bound for the omitted tail; it is not a claim
911        // that the command emitted exactly one additional byte.
912        signal.coverage.kind = CoverageKindV1::Partial;
913        signal.coverage.bytes_total = signal.coverage.bytes_inspected.saturating_add(1);
914        signal.coverage.chunks_total = signal.coverage.chunks_inspected.saturating_add(1);
915        signal.coverage.reason = Some(partial_reason.clone());
916    }
917    admission.counts.incomplete = admission.counts.incomplete.max(1);
918    if policy.detectors_mandatory() {
919        admission.decision.disposition = ContextDispositionV1::Deny;
920        admission.text = None;
921    }
922}
923
924/// Source text admitted for delivery, with the original classification's
925/// persistence decision preserved for downstream caches and indexes.
926#[derive(Debug, Clone, PartialEq, Eq)]
927pub struct SourceAdmission {
928    pub text: String,
929    pub storable: bool,
930    /// Classification assigned by the original Gateway inspection.
931    pub(crate) classification: ClassificationV1,
932}
933
934/// Admit a source the agent is about to read, at the read choke point.
935///
936/// Returns the admitted text, or a content-free `PermissionDenied` naming the
937/// reason codes. With `preserve_bytes` (signed snapshots) any rewrite is
938/// refused instead of silently changing a digest-bound source.
939pub fn admit_source(
940    text: &str,
941    path: &str,
942    preserve_bytes: bool,
943) -> Result<String, std::io::Error> {
944    admit_source_with_storage(text, path, preserve_bytes, OutputCompleteness::complete())
945        .map(|admission| admission.text)
946}
947
948/// Admit a source and preserve whether its original Gateway classification may
949/// enter derived stores. Incomplete reads never seed the clean memo or stores.
950pub(crate) fn admit_source_with_storage(
951    text: &str,
952    path: &str,
953    preserve_bytes: bool,
954    completeness: OutputCompleteness,
955) -> Result<SourceAdmission, std::io::Error> {
956    admit_source_inner(text, Some(path), preserve_bytes, completeness, None)
957}
958
959/// Admit an in-memory source without assigning it a synthetic filesystem path.
960pub fn admit_context_source(text: &str) -> Result<SourceAdmission, std::io::Error> {
961    admit_source_inner(text, None, false, OutputCompleteness::complete(), None)
962}
963
964/// [`admit_context_source`] for text that is bound by a digest or signature
965/// and therefore cannot be delivered rewritten: a mask is a refusal, and it
966/// is recorded as one (never as an admitted redaction of a copy that did not
967/// leave).
968pub(crate) fn admit_bound_context_source(text: &str) -> Result<SourceAdmission, std::io::Error> {
969    admit_source_inner(text, None, true, OutputCompleteness::complete(), None)
970}
971
972/// Admit a rooted source with one caller-captured Gateway snapshot. Hosted
973/// generation builders use this so a multi-file build cannot mix policies if
974/// global configuration changes during the scan; publication must reauthorize
975/// the same snapshot afterward.
976pub(crate) fn admit_source_with_snapshot(
977    text: &str,
978    path: &str,
979    preserve_bytes: bool,
980    completeness: OutputCompleteness,
981    snapshot: &stores::StoreAdmission,
982) -> Result<SourceAdmission, std::io::Error> {
983    admit_source_inner(
984        text,
985        Some(path),
986        preserve_bytes,
987        completeness,
988        Some(snapshot.admission_policy()),
989    )
990}
991
992fn admit_source_inner(
993    text: &str,
994    path: Option<&str>,
995    preserve_bytes: bool,
996    completeness: OutputCompleteness,
997    policy_snapshot: Option<&AdmissionPolicy>,
998) -> Result<SourceAdmission, std::io::Error> {
999    admit_source_typed(text, path, preserve_bytes, completeness, policy_snapshot).map_err(
1000        |reasons| {
1001            let hint = withheld_remedy(&reasons);
1002            let reasons: Vec<&str> = reasons.iter().map(ReasonCodeV1::as_str).collect();
1003            std::io::Error::new(
1004                std::io::ErrorKind::PermissionDenied,
1005                format!(
1006                    "source content withheld by the context gateway ({}){hint}",
1007                    reasons.join(", ")
1008                ),
1009            )
1010        },
1011    )
1012}
1013
1014/// The setting behind a withheld read, so the user can tell an intended policy
1015/// from a misconfiguration (814 refusals in 30 days carried only the code —
1016/// focus analysis 2026-10-15). Fixed text: never content, never a path.
1017fn withheld_remedy(reasons: &[ReasonCodeV1]) -> &'static str {
1018    let has = |code: &str| reasons.iter().any(|reason| reason.as_str() == code);
1019    if has("gateway.config_unloadable") {
1020        " — the global config.toml could not be loaded; fix it (lean-ctx doctor) to restore reads"
1021    } else if has("coverage.partial") || has("coverage.budget_exhausted") {
1022        " — the object exceeds [context_gateway] max_inspected_bytes and governed/sovereign mode \
1023         withholds what detectors cannot fully inspect"
1024    } else if has("detector.timed_out") {
1025        " — a detector exceeded [context_gateway] detector_timeout_ms in governed/sovereign mode"
1026    } else if has("classification.blocked") {
1027        " — a classification marking matched [context_gateway] classification = redact|block"
1028    } else if has("secret.blocked") {
1029        " — a secret matched [context_gateway] secrets = \"block\""
1030    } else if has("injection.blocked") {
1031        " — prompt-injection text matched [context_gateway] injection = \"block\""
1032    } else if has("redaction.incomplete") {
1033        " — a redaction left a detectable value behind, so nothing is delivered"
1034    } else if has("policy.content_withheld") {
1035        " — withheld by the project or organization policy"
1036    } else {
1037        ""
1038    }
1039}
1040
1041/// [`admit_source_inner`] with the refusal as typed reason codes, for carriers
1042/// that render their own content-free message (see `carrier`).
1043fn admit_source_typed(
1044    text: &str,
1045    path: Option<&str>,
1046    preserve_bytes: bool,
1047    completeness: OutputCompleteness,
1048    policy_snapshot: Option<&AdmissionPolicy>,
1049) -> Result<SourceAdmission, Vec<ReasonCodeV1>> {
1050    // Inspect original content before a Gateway redaction or clean memo can
1051    // remove a signal required by the active project policy.
1052    let project_original = crate::core::policy::content::protect_active(text);
1053    let project_rewrote = project_original
1054        .as_ref()
1055        .is_ok_and(|admitted| admitted.as_ref() != text);
1056    let policy = policy_snapshot
1057        .cloned()
1058        .unwrap_or_else(carrier::current_policy);
1059    let fingerprint = policy.fingerprint();
1060    let capture = capture::current();
1061    if let Some(capture) = &capture {
1062        capture.bind_policy(policy.gateway.mode, fingerprint_digest(&fingerprint));
1063    }
1064    // Clean memo: only complete reads and built-in detectors are pure functions
1065    // of the input. Partial or lossy reads must be inspected with their flags.
1066    let memo_key = path.and_then(|path| {
1067        (completeness.is_complete()
1068            && project_original.is_ok()
1069            && !project_rewrote
1070            && policy.semantic.is_none()
1071            && !policy.config_unloadable()
1072            && policy.gateway_switch())
1073        .then(|| clean::key(&fingerprint, path, text))
1074    });
1075    if let Some(key) = &memo_key
1076        && let Some(decision) = clean::get_source(key)
1077    {
1078        if let Some(capture) = &capture {
1079            capture.record(&decision.decision, AdmissionCounts::default());
1080        }
1081        return Ok(SourceAdmission {
1082            text: project_original
1083                .expect("memo key requires accepted project policy")
1084                .into_owned(),
1085            storable: decision.storable,
1086            classification: decision.classification,
1087        });
1088    }
1089
1090    let mut admission = admit(text, path.map(Path::new), &policy);
1091    mark_capture_incomplete(&mut admission, completeness, &policy);
1092    let storable = completeness.is_complete() && admission.storable();
1093    let gateway_admitted = admission.text.clone();
1094    let mut project_admitted = project_original
1095        .as_deref()
1096        .ok()
1097        .zip(gateway_admitted.as_deref())
1098        .and_then(|(project_original, gateway_admitted)| {
1099            compose_active_gateway_output(project_original, gateway_admitted, &policy, completeness)
1100        });
1101    let mut refusal_reason = if project_original.is_err()
1102        || (gateway_admitted.is_some() && project_admitted.is_none())
1103    {
1104        Some("policy.content_withheld")
1105    } else {
1106        None
1107    };
1108
1109    if let Some(admitted) = &project_admitted {
1110        if project_rewrote
1111            || gateway_admitted
1112                .as_deref()
1113                .is_some_and(|gateway| gateway != admitted)
1114        {
1115            admission.text = Some(admitted.clone());
1116            mark_project_redaction(&mut admission);
1117        }
1118    }
1119    if preserve_bytes
1120        && let Some(gateway_admitted) = gateway_admitted.as_deref()
1121        && let Some(project_admitted_text) = project_admitted.as_deref()
1122        && preserve_bytes_rewrite_reason(
1123            text,
1124            gateway_admitted,
1125            project_admitted_text,
1126            project_rewrote,
1127        )
1128        .is_some()
1129    {
1130        project_admitted = None;
1131        refusal_reason = Some("source.bytes_not_preserved");
1132    }
1133    if let Some(reason_code) = refusal_reason {
1134        mark_policy_withheld(&mut admission, reason_code);
1135    }
1136    if let Some(capture) = &capture {
1137        capture.record(&admission.decision, admission.counts);
1138    }
1139
1140    let Some(project_admitted) = project_admitted else {
1141        return Err(admission.decision.reason_codes);
1142    };
1143
1144    if let Some(key) = memo_key
1145        && admission.disposition() == ContextDispositionV1::Allow
1146        && admission.decision.reason_codes.is_empty()
1147        && admission.text.as_deref() == Some(text)
1148        && project_admitted == text
1149    {
1150        clean::insert_source(
1151            key,
1152            admission.decision.clone(),
1153            admission.classification,
1154            admission.storable(),
1155        );
1156    }
1157    let redaction_counts = policy.gateway.hud.redaction_counts_in_band();
1158    if admission.hud_line_placed(redaction_counts).is_some() {
1159        notes::record(
1160            project_admitted.as_ref(),
1161            notes::Note {
1162                counts: admission.counts,
1163                decision: admission.decision.clone(),
1164                redaction_counts,
1165            },
1166        );
1167    }
1168    Ok(SourceAdmission {
1169        text: project_admitted,
1170        storable,
1171        classification: admission.classification,
1172    })
1173}
1174
1175fn preserve_bytes_rewrite_reason(
1176    source: &str,
1177    gateway_admitted: &str,
1178    project_admitted: &str,
1179    project_rewrote_original: bool,
1180) -> Option<&'static str> {
1181    if project_rewrote_original || project_admitted != gateway_admitted {
1182        Some("source content withheld: an active policy would rewrite a digest-bound source")
1183    } else if gateway_admitted != source {
1184        Some("source content withheld: the context gateway would rewrite a digest-bound source")
1185    } else {
1186        None
1187    }
1188}
1189
1190/// Admit an object no built-in detector can read (an image). Every enabled
1191/// detector reports `unsupported`; developer mode delivers it with a note,
1192/// governed and sovereign modes withhold it.
1193#[must_use]
1194pub fn admit_uninspectable(path: Option<&Path>, bytes: u64, policy: &AdmissionPolicy) -> Admission {
1195    let gateway = &policy.gateway;
1196    let object = content_digest(&format!("uninspectable:{bytes}"));
1197    let actions = policy.actions();
1198    let signals = detectors::unsupported_signals(&actions, bytes);
1199    let required = signals.iter().any(|signal| {
1200        signal.coverage.kind == lean_ctx_protocol::context_gateway::CoverageKindV1::Unsupported
1201    });
1202    if policy.config_unloadable() || !policy.gateway_switch() || !required {
1203        return admit("", path, policy);
1204    }
1205    let restricted_path =
1206        path.is_some_and(|p| crate::core::io_boundary::is_secret_like(p).is_some());
1207    let mandatory = policy.detectors_mandatory();
1208    Admission {
1209        text: (!mandatory).then(String::new),
1210        classification: if restricted_path {
1211            ClassificationV1::Restricted
1212        } else {
1213            gateway.mode.unclassified()
1214        },
1215        trust: TrustLevelV1::Unknown,
1216        decision: ContextDecisionV1 {
1217            object,
1218            disposition: if mandatory {
1219                ContextDispositionV1::Deny
1220            } else {
1221                ContextDispositionV1::Allow
1222            },
1223            reason_codes: vec![reason("coverage.unsupported_media")],
1224            signals,
1225            required_transformations: Vec::new(),
1226        },
1227        counts: AdmissionCounts {
1228            incomplete: 1,
1229            ..AdmissionCounts::default()
1230        },
1231    }
1232}
1233
1234/// Media gate for the read path: `Ok(note)` to deliver with a visible note,
1235/// or a content-free `PermissionDenied` when the media must be withheld.
1236pub fn admit_media(path: &str, bytes: u64) -> Result<Option<String>, std::io::Error> {
1237    let policy = carrier::current_policy();
1238    let admission = admit_uninspectable(Some(Path::new(path)), bytes, &policy);
1239    if let Some(capture) = capture::current() {
1240        capture.bind_policy(
1241            policy.gateway.mode,
1242            fingerprint_digest(&policy.fingerprint()),
1243        );
1244        capture.record(&admission.decision, admission.counts);
1245    }
1246    if admission.text.is_none() {
1247        return Err(std::io::Error::new(
1248            std::io::ErrorKind::PermissionDenied,
1249            "media withheld by the context gateway (coverage.unsupported_media)",
1250        ));
1251    }
1252    Ok((admission.counts.incomplete > 0)
1253        .then(|| "[lean-ctx gateway: media not inspected (coverage.unsupported_media)]".to_owned()))
1254}
1255
1256/// Forget every memoized clean admission (measurements of cold reads).
1257#[cfg(any(test, feature = "test-support"))]
1258pub fn clear_clean_memo() {
1259    clean::clear();
1260}
1261
1262/// The HUD line recorded when exactly `admitted` was produced, stated for
1263/// `delivered`: the part of `admitted` a tool actually returns (a line
1264/// window, a compressed view). Masked values are counted from the markers
1265/// `delivered` carries, never above the object's own counts, so a window
1266/// without a masked value gets no redaction count — and no line when nothing
1267/// else applies (#2135). Every other part of the note describes the object.
1268#[must_use]
1269pub fn note_for(admitted: &str, delivered: &str) -> Option<String> {
1270    let note = notes::lookup(admitted)?;
1271    let mut counts = note.counts;
1272    if note.decision.disposition == ContextDispositionV1::AllowRedacted {
1273        let pii = crate::core::input_filters::pii::checksummed_marker_count(delivered);
1274        let other = delivered
1275            .matches("[REDACTED:")
1276            .count()
1277            .saturating_sub(pii + delivered.matches(INJECTION_MARKER).count());
1278        counts.pii = counts.pii.min(saturating_u32(pii));
1279        counts.secrets = counts.secrets.min(saturating_u32(other));
1280    }
1281    render_hud(counts, &note.decision, false, note.redaction_counts)
1282}
1283
1284const INJECTION_MARKER: &str = "[REDACTED:prompt-injection]";
1285
1286fn saturating_u32(n: usize) -> u32 {
1287    u32::try_from(n).unwrap_or(u32::MAX)
1288}
1289
1290fn reason(code: &str) -> ReasonCodeV1 {
1291    ReasonCodeV1::new(code).expect("built-in reason codes are valid")
1292}
1293
1294/// Record in the running call's receipt that a derived copy admitted before
1295/// (at its cache fill) was asked of its source again at this serve and
1296/// refused: the after side of the revocation boundary (C04). The decision is
1297/// a denial whose reason is the source state (`source.revoked`, …); its
1298/// object is one fixed digest for every such refusal, so the receipt names
1299/// neither the item nor its text and reads the same for every item withheld
1300/// this way (K02/S02). No capture: nothing is recorded.
1301pub(crate) fn record_recheck_refusal(
1302    admission: &stores::StoreAdmission,
1303    validity: lean_ctx_protocol::context_lineage::SourceValidityV1,
1304) {
1305    let Some(capture) = capture::current() else {
1306        return;
1307    };
1308    let policy = admission.admission_policy();
1309    capture.bind_policy(
1310        policy.gateway.mode,
1311        fingerprint_digest(&policy.fingerprint()),
1312    );
1313    let decision = ContextDecisionV1 {
1314        object: fingerprint_digest(b"source-recheck-refused"),
1315        disposition: ContextDispositionV1::Deny,
1316        reason_codes: vec![reason(validity.reason_code())],
1317        signals: Vec::new(),
1318        required_transformations: Vec::new(),
1319    };
1320    capture.record(&decision, AdmissionCounts::default());
1321}
1322
1323/// SHA-256 of the canonical policy fingerprint: the receipt's policy digest.
1324fn fingerprint_digest(fingerprint: &[u8]) -> Sha256Digest {
1325    let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(fingerprint));
1326    Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
1327}
1328
1329fn content_digest(text: &str) -> Sha256Digest {
1330    let digest = crate::core::agent_identity::hex_encode(&Sha256::digest(text.as_bytes()));
1331    Sha256Digest::new(format!("sha256:{digest}")).expect("SHA-256 digest is canonical")
1332}
1333
1334#[cfg(test)]
1335mod shell_admission_regression_tests {
1336    use super::*;
1337
1338    #[test]
1339    fn rendered_notice_uses_a_newline_and_survives_empty_text() {
1340        let admission = ToolOutputAdmission {
1341            text: Some("body".to_string()),
1342            notice: Some("[notice]".to_string()),
1343        };
1344        assert_eq!(admission.rendered(), "[notice]\nbody");
1345
1346        let empty = ToolOutputAdmission {
1347            text: Some(String::new()),
1348            notice: Some("[notice]".to_string()),
1349        };
1350        assert_eq!(empty.rendered(), "[notice]");
1351    }
1352
1353    #[test]
1354    fn incomplete_capture_is_never_persistable() {
1355        let policy = AdmissionPolicy::builtin(
1356            ContextGatewayConfig::default(),
1357            SecretDetectionConfig::default(),
1358        );
1359        let incomplete = OutputCompleteness {
1360            reader_incomplete: true,
1361            ..OutputCompleteness::complete()
1362        };
1363        assert!(
1364            admit_output_for_storage("partial output", incomplete, &policy).is_none(),
1365            "an incomplete prefix must not seed a cache"
1366        );
1367    }
1368
1369    /// #2164: a background job whose command exited while a process it left
1370    /// running still holds the pipe keeps its captured output for the status
1371    /// poll; every other gap, and a mandatory gateway, still withholds it.
1372    #[test]
1373    fn background_poll_keeps_a_capture_whose_only_gap_is_a_held_pipe() {
1374        let developer = AdmissionPolicy::builtin(
1375            ContextGatewayConfig::default(),
1376            SecretDetectionConfig::default(),
1377        );
1378        let held_pipe = OutputCompleteness {
1379            reader_incomplete: true,
1380            ..OutputCompleteness::complete()
1381        };
1382        assert_eq!(
1383            admit_output_for_poll("hello stdout\nboom stderr", held_pipe, &developer).as_deref(),
1384            Some("hello stdout\nboom stderr")
1385        );
1386        for other_gap in [
1387            OutputCompleteness {
1388                truncated: true,
1389                ..held_pipe
1390            },
1391            OutputCompleteness {
1392                timed_out: true,
1393                ..held_pipe
1394            },
1395            OutputCompleteness {
1396                cancelled: true,
1397                ..OutputCompleteness::complete()
1398            },
1399        ] {
1400            assert!(admit_output_for_poll("partial", other_gap, &developer).is_none());
1401        }
1402
1403        let governed = AdmissionPolicy::builtin(
1404            ContextGatewayConfig {
1405                mode: GatewayModeV1::Governed,
1406                ..ContextGatewayConfig::default()
1407            },
1408            SecretDetectionConfig::default(),
1409        );
1410        if governed.enabled_effective() {
1411            assert!(admit_output_for_poll("partial", held_pipe, &governed).is_none());
1412        }
1413    }
1414
1415    #[test]
1416    fn mandatory_gateway_marks_and_withholds_incomplete_capture() {
1417        let gateway = ContextGatewayConfig {
1418            mode: GatewayModeV1::Governed,
1419            ..ContextGatewayConfig::default()
1420        };
1421        let policy = AdmissionPolicy::builtin(gateway, SecretDetectionConfig::default());
1422        let incomplete = OutputCompleteness {
1423            truncated: true,
1424            ..OutputCompleteness::complete()
1425        };
1426        let mut admission = admit("partial output", None, &policy);
1427        mark_capture_incomplete(&mut admission, incomplete, &policy);
1428
1429        if policy.enabled_effective() {
1430            assert!(admission.text.is_none());
1431            assert!(
1432                admission
1433                    .decision
1434                    .reason_codes
1435                    .iter()
1436                    .any(|code| code.as_str() == "coverage.output_partial")
1437            );
1438        }
1439    }
1440
1441    /// AWS's documented example access key, assembled so the source carries
1442    /// no literal the public secret scanner flags.
1443    fn aws_documentation_key() -> String {
1444        ["AK", "IA", "IOSFODNN7EXAMPLE"].concat()
1445    }
1446
1447    #[test]
1448    fn overlapping_policy_and_gateway_redactions_withhold_noncomposable_output() {
1449        let pack = crate::core::policy::parse(&format!(
1450            "name = \"composition-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n[redaction]\ncustomer = 'customer {}'",
1451            aws_documentation_key()
1452        ))
1453        .expect("valid policy");
1454        let _project_policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(
1455            crate::core::policy::resolve(&pack).expect("resolved policy"),
1456        ));
1457        let original = format!("customer {}", aws_documentation_key());
1458        let original = original.as_str();
1459        let project_original = crate::core::policy::content::protect_active(original)
1460            .expect("project redaction is allowed")
1461            .into_owned();
1462        let policy = AdmissionPolicy::builtin(
1463            ContextGatewayConfig::default(),
1464            SecretDetectionConfig::default(),
1465        );
1466        let gateway_output = admit(original, None, &policy)
1467            .text
1468            .expect("developer gateway redacts and delivers");
1469        assert_ne!(project_original.as_str(), original);
1470        assert_ne!(gateway_output.as_str(), original);
1471        assert!(
1472            compose_active_gateway_output(
1473                &project_original,
1474                &gateway_output,
1475                &policy,
1476                OutputCompleteness::complete(),
1477            )
1478            .is_none(),
1479            "overlapping redactions that depend on order must be withheld"
1480        );
1481    }
1482}
1483
1484#[cfg(test)]
1485mod tests;
1486
1487#[cfg(test)]
1488mod receipt_tests;
1489
1490#[cfg(test)]
1491mod store_tests;
1492
1493#[cfg(test)]
1494mod egress_tests;
1495
1496#[cfg(test)]
1497mod egress_api_tests;