Skip to main content

lean_ctx/core/sensitivity/
mod.rs

1//! Per-item sensitivity model with a uniform policy floor (#212).
2//!
3//! Assigns a [`SensitivityLevel`] to context items (tool outputs, knowledge
4//! facts, file paths) from path + content signals, and lets a configurable
5//! `policy_floor` drop or redact anything at/above the floor *before* it reaches
6//! the model.
7//!
8//! Design goals:
9//! - **No-op by default.** Disabled until `sensitivity.enabled = true` (or the
10//!   `LEAN_CTX_SENSITIVITY` env override). Nothing changes for existing users.
11//! - **Honest classification.** Only high-precision signals raise a level:
12//!   secret-like paths and detected secrets → `Secret`; Luhn-validated card
13//!   numbers and IBANs → `Confidential`. No speculative heuristics.
14//! - **Uniform enforcement.** One [`enforce_text`] entry point used at the
15//!   pre-prompt choke points (tool output, knowledge injection).
16
17mod classify;
18
19pub use classify::{classify, classify_content, classify_path};
20
21use serde::{Deserialize, Serialize};
22use std::path::Path;
23
24/// Ordered sensitivity classification.
25///
26/// The derived `Ord` drives every `level >= floor` comparison, so the
27/// declaration order is significant: `Public < Internal < Confidential < Secret`.
28#[derive(
29    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default, Hash,
30)]
31#[serde(rename_all = "snake_case")]
32pub enum SensitivityLevel {
33    /// Safe to send to the model (default).
34    #[default]
35    Public,
36    /// Internal-only material; reserved for explicit/manual tagging.
37    Internal,
38    /// Personally identifiable / regulated data (card numbers, IBANs).
39    Confidential,
40    /// Secrets and credentials. Must never reach the model when enforced.
41    Secret,
42}
43
44impl SensitivityLevel {
45    pub fn as_str(self) -> &'static str {
46        match self {
47            SensitivityLevel::Public => "public",
48            SensitivityLevel::Internal => "internal",
49            SensitivityLevel::Confidential => "confidential",
50            SensitivityLevel::Secret => "secret",
51        }
52    }
53
54    /// Tolerant parse from a config/env string. Returns `None` on unknown input
55    /// so callers can fall back to the default without panicking.
56    pub fn parse(s: &str) -> Option<Self> {
57        match s.trim().to_ascii_lowercase().as_str() {
58            "public" | "none" | "" => Some(SensitivityLevel::Public),
59            "internal" => Some(SensitivityLevel::Internal),
60            "confidential" | "pii" => Some(SensitivityLevel::Confidential),
61            "secret" | "secrets" | "credential" | "credentials" => Some(SensitivityLevel::Secret),
62            _ => None,
63        }
64    }
65}
66
67/// Projection onto the Context Gateway lattice; secrets are `Restricted`.
68impl From<SensitivityLevel> for lean_ctx_protocol::context_gateway::ClassificationV1 {
69    fn from(level: SensitivityLevel) -> Self {
70        match level {
71            SensitivityLevel::Public => Self::Public,
72            SensitivityLevel::Internal => Self::Internal,
73            SensitivityLevel::Confidential => Self::Confidential,
74            SensitivityLevel::Secret => Self::Restricted,
75        }
76    }
77}
78
79/// What to do when an item meets or exceeds the floor.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
81#[serde(rename_all = "snake_case")]
82pub enum FloorAction {
83    /// Mask the offending spans (secrets, cards, IBANs), keep the rest.
84    #[default]
85    Redact,
86    /// Replace the whole item with a short notice.
87    Drop,
88}
89
90impl FloorAction {
91    pub fn as_str(self) -> &'static str {
92        match self {
93            FloorAction::Redact => "redact",
94            FloorAction::Drop => "drop",
95        }
96    }
97}
98
99/// Configuration for the sensitivity policy floor.
100///
101/// Mirrors the `ArchiveConfig` pattern (`#[serde(default)]` + explicit
102/// `Default`) so it round-trips cleanly through TOML and stays a no-op until
103/// enabled.
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(default)]
106pub struct SensitivityConfig {
107    /// Master switch. `false` → fully no-op (default).
108    pub enabled: bool,
109    /// Items classified at or above this level are dropped/redacted.
110    pub policy_floor: SensitivityLevel,
111    /// How to enforce the floor.
112    pub action: FloorAction,
113}
114
115impl Default for SensitivityConfig {
116    fn default() -> Self {
117        Self {
118            enabled: false,
119            policy_floor: SensitivityLevel::Secret,
120            action: FloorAction::Redact,
121        }
122    }
123}
124
125impl SensitivityConfig {
126    /// Effective enabled flag, honoring the `LEAN_CTX_SENSITIVITY` env override
127    /// (`0|false|off` disables, anything else enables).
128    pub fn enabled_effective(&self) -> bool {
129        if let Ok(v) = std::env::var("LEAN_CTX_SENSITIVITY") {
130            return !matches!(v.trim(), "0" | "false" | "off");
131        }
132        self.enabled
133    }
134
135    /// Fold the active persona's `sensitivity_floor` (persona-spec-v1) into
136    /// this config. A floor above `Public` turns enforcement on and can only
137    /// *tighten* the floor (`min`, since lower levels enforce more) — it never
138    /// relaxes an explicit `[sensitivity]` setting. `Public` (the `coding`
139    /// default) is the "no opinion" sentinel: the config passes through
140    /// unchanged. The `LEAN_CTX_SENSITIVITY=off` kill switch still wins via
141    /// [`Self::enabled_effective`].
142    #[must_use]
143    pub fn with_persona_floor(mut self, floor: SensitivityLevel) -> Self {
144        if floor > SensitivityLevel::Public {
145            if self.enabled {
146                self.policy_floor = self.policy_floor.min(floor);
147            } else {
148                self.enabled = true;
149                self.policy_floor = floor;
150            }
151        }
152        self
153    }
154}
155
156/// Outcome of enforcing the floor on a single text item.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum Enforced {
159    /// Below floor (or disabled): returned unchanged.
160    Pass(String),
161    /// At/above floor with `Redact`: offending spans masked.
162    Redacted {
163        text: String,
164        level: SensitivityLevel,
165    },
166    /// At/above floor with `Drop`: replaced by a notice.
167    Dropped {
168        notice: String,
169        level: SensitivityLevel,
170    },
171}
172
173impl Enforced {
174    /// The text to actually emit, regardless of variant.
175    pub fn into_text(self) -> String {
176        match self {
177            Enforced::Pass(t) => t,
178            Enforced::Redacted { text, .. } => text,
179            Enforced::Dropped { notice, .. } => notice,
180        }
181    }
182
183    /// True if the floor changed the content.
184    pub fn was_enforced(&self) -> bool {
185        !matches!(self, Enforced::Pass(_))
186    }
187}
188
189/// Apply the configured floor to a text item (e.g. a tool output).
190///
191/// `path` is an optional source hint used for path-based classification.
192/// Returns [`Enforced::Pass`] verbatim when disabled or below the floor.
193pub fn enforce_text(text: String, path: Option<&Path>, cfg: &SensitivityConfig) -> Enforced {
194    if !cfg.enabled_effective() {
195        return Enforced::Pass(text);
196    }
197    let level = classify(path, &text);
198    if level < cfg.policy_floor {
199        return Enforced::Pass(text);
200    }
201    match cfg.action {
202        FloorAction::Drop => {
203            let notice = format!(
204                "[lean-ctx: content withheld — sensitivity `{}` ≥ policy floor `{}`]",
205                level.as_str(),
206                cfg.policy_floor.as_str()
207            );
208            Enforced::Dropped { notice, level }
209        }
210        FloorAction::Redact => {
211            let redacted = classify::redact_sensitive(&text);
212            Enforced::Redacted {
213                text: redacted,
214                level,
215            }
216        }
217    }
218}
219
220/// Decide whether `fact_level` is blocked by the floor. Used for structured
221/// items (knowledge facts) where the level is known/stored rather than derived
222/// from free text. No-op (never blocked) when disabled.
223pub fn floor_blocks(fact_level: SensitivityLevel, cfg: &SensitivityConfig) -> bool {
224    cfg.enabled_effective() && fact_level >= cfg.policy_floor
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn levels_are_ordered() {
233        assert!(SensitivityLevel::Public < SensitivityLevel::Internal);
234        assert!(SensitivityLevel::Internal < SensitivityLevel::Confidential);
235        assert!(SensitivityLevel::Confidential < SensitivityLevel::Secret);
236    }
237
238    #[test]
239    fn gateway_projection_preserves_order_and_restricts_secrets() {
240        use lean_ctx_protocol::context_gateway::ClassificationV1;
241        let levels = [
242            SensitivityLevel::Public,
243            SensitivityLevel::Internal,
244            SensitivityLevel::Confidential,
245            SensitivityLevel::Secret,
246        ];
247        for pair in levels.windows(2) {
248            assert!(ClassificationV1::from(pair[0]) < ClassificationV1::from(pair[1]));
249        }
250        assert_eq!(
251            ClassificationV1::from(SensitivityLevel::Secret),
252            ClassificationV1::Restricted
253        );
254    }
255
256    #[test]
257    fn parse_is_tolerant() {
258        assert_eq!(
259            SensitivityLevel::parse("SECRET"),
260            Some(SensitivityLevel::Secret)
261        );
262        assert_eq!(
263            SensitivityLevel::parse("pii"),
264            Some(SensitivityLevel::Confidential)
265        );
266        assert_eq!(SensitivityLevel::parse(""), Some(SensitivityLevel::Public));
267        assert_eq!(SensitivityLevel::parse("nope"), None);
268    }
269
270    #[test]
271    fn disabled_is_noop_even_for_secrets() {
272        let cfg = SensitivityConfig::default(); // enabled = false
273        let secret = concat!("token = gh", "p_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789").to_string();
274        let out = enforce_text(secret.clone(), None, &cfg);
275        assert_eq!(out, Enforced::Pass(secret));
276    }
277
278    #[test]
279    fn below_floor_passes_unchanged() {
280        let cfg = SensitivityConfig {
281            enabled: true,
282            policy_floor: SensitivityLevel::Secret,
283            action: FloorAction::Redact,
284        };
285        let benign = "just a normal log line with no secrets".to_string();
286        assert_eq!(
287            enforce_text(benign.clone(), None, &cfg),
288            Enforced::Pass(benign)
289        );
290    }
291
292    #[test]
293    fn drop_action_withholds_secret() {
294        let cfg = SensitivityConfig {
295            enabled: true,
296            policy_floor: SensitivityLevel::Secret,
297            action: FloorAction::Drop,
298        };
299        let secret = concat!("AWS key AK", "IAIOSFODNN7EXAMPLE leaked").to_string();
300        match enforce_text(secret, None, &cfg) {
301            Enforced::Dropped { level, notice } => {
302                assert_eq!(level, SensitivityLevel::Secret);
303                assert!(notice.contains("withheld"));
304            }
305            other => panic!("expected Dropped, got {other:?}"),
306        }
307    }
308
309    #[test]
310    fn redact_action_masks_secret_keeps_rest() {
311        let cfg = SensitivityConfig {
312            enabled: true,
313            policy_floor: SensitivityLevel::Secret,
314            action: FloorAction::Redact,
315        };
316        let text = concat!("prefix AK", "IAIOSFODNN7EXAMPLE suffix").to_string();
317        match enforce_text(text, None, &cfg) {
318            Enforced::Redacted { text, level } => {
319                assert_eq!(level, SensitivityLevel::Secret);
320                assert!(text.contains("prefix"));
321                assert!(text.contains("suffix"));
322                assert!(!text.contains(concat!("AK", "IAIOSFODNN7EXAMPLE")));
323            }
324            other => panic!("expected Redacted, got {other:?}"),
325        }
326    }
327
328    #[test]
329    fn persona_floor_public_is_a_noop() {
330        // The coding default ("public") must never flip enforcement on.
331        let cfg = SensitivityConfig::default().with_persona_floor(SensitivityLevel::Public);
332        assert_eq!(cfg, SensitivityConfig::default());
333    }
334
335    #[test]
336    fn persona_floor_enables_enforcement_when_config_is_off() {
337        // lead-gen declares "confidential" → PII protection out of the box.
338        let cfg = SensitivityConfig::default().with_persona_floor(SensitivityLevel::Confidential);
339        assert!(cfg.enabled);
340        assert_eq!(cfg.policy_floor, SensitivityLevel::Confidential);
341    }
342
343    #[test]
344    fn persona_floor_only_tightens_an_enabled_config() {
345        let base = SensitivityConfig {
346            enabled: true,
347            policy_floor: SensitivityLevel::Secret,
348            action: FloorAction::Redact,
349        };
350        // Persona floor below the configured floor → tightened (min wins).
351        let tightened = base.clone().with_persona_floor(SensitivityLevel::Internal);
352        assert_eq!(tightened.policy_floor, SensitivityLevel::Internal);
353        // Config already stricter than the persona → unchanged.
354        let strict = SensitivityConfig {
355            enabled: true,
356            policy_floor: SensitivityLevel::Internal,
357            action: FloorAction::Redact,
358        };
359        let kept = strict
360            .clone()
361            .with_persona_floor(SensitivityLevel::Confidential);
362        assert_eq!(kept.policy_floor, SensitivityLevel::Internal);
363    }
364
365    #[test]
366    fn floor_blocks_respects_level_and_enabled() {
367        let mut cfg = SensitivityConfig {
368            enabled: true,
369            policy_floor: SensitivityLevel::Confidential,
370            action: FloorAction::Drop,
371        };
372        assert!(floor_blocks(SensitivityLevel::Secret, &cfg));
373        assert!(floor_blocks(SensitivityLevel::Confidential, &cfg));
374        assert!(!floor_blocks(SensitivityLevel::Internal, &cfg));
375        cfg.enabled = false;
376        assert!(!floor_blocks(SensitivityLevel::Secret, &cfg));
377    }
378}