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/// What to do when an item meets or exceeds the floor.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
69#[serde(rename_all = "snake_case")]
70pub enum FloorAction {
71    /// Mask the offending spans (secrets, cards, IBANs), keep the rest.
72    #[default]
73    Redact,
74    /// Replace the whole item with a short notice.
75    Drop,
76}
77
78impl FloorAction {
79    pub fn as_str(self) -> &'static str {
80        match self {
81            FloorAction::Redact => "redact",
82            FloorAction::Drop => "drop",
83        }
84    }
85}
86
87/// Configuration for the sensitivity policy floor.
88///
89/// Mirrors the `ArchiveConfig` pattern (`#[serde(default)]` + explicit
90/// `Default`) so it round-trips cleanly through TOML and stays a no-op until
91/// enabled.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(default)]
94pub struct SensitivityConfig {
95    /// Master switch. `false` → fully no-op (default).
96    pub enabled: bool,
97    /// Items classified at or above this level are dropped/redacted.
98    pub policy_floor: SensitivityLevel,
99    /// How to enforce the floor.
100    pub action: FloorAction,
101}
102
103impl Default for SensitivityConfig {
104    fn default() -> Self {
105        Self {
106            enabled: false,
107            policy_floor: SensitivityLevel::Secret,
108            action: FloorAction::Redact,
109        }
110    }
111}
112
113impl SensitivityConfig {
114    /// Effective enabled flag, honoring the `LEAN_CTX_SENSITIVITY` env override
115    /// (`0|false|off` disables, anything else enables).
116    pub fn enabled_effective(&self) -> bool {
117        if let Ok(v) = std::env::var("LEAN_CTX_SENSITIVITY") {
118            return !matches!(v.trim(), "0" | "false" | "off");
119        }
120        self.enabled
121    }
122
123    /// Fold the active persona's `sensitivity_floor` (persona-spec-v1) into
124    /// this config. A floor above `Public` turns enforcement on and can only
125    /// *tighten* the floor (`min`, since lower levels enforce more) — it never
126    /// relaxes an explicit `[sensitivity]` setting. `Public` (the `coding`
127    /// default) is the "no opinion" sentinel: the config passes through
128    /// unchanged. The `LEAN_CTX_SENSITIVITY=off` kill switch still wins via
129    /// [`Self::enabled_effective`].
130    #[must_use]
131    pub fn with_persona_floor(mut self, floor: SensitivityLevel) -> Self {
132        if floor > SensitivityLevel::Public {
133            if self.enabled {
134                self.policy_floor = self.policy_floor.min(floor);
135            } else {
136                self.enabled = true;
137                self.policy_floor = floor;
138            }
139        }
140        self
141    }
142}
143
144/// Outcome of enforcing the floor on a single text item.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Enforced {
147    /// Below floor (or disabled): returned unchanged.
148    Pass(String),
149    /// At/above floor with `Redact`: offending spans masked.
150    Redacted {
151        text: String,
152        level: SensitivityLevel,
153    },
154    /// At/above floor with `Drop`: replaced by a notice.
155    Dropped {
156        notice: String,
157        level: SensitivityLevel,
158    },
159}
160
161impl Enforced {
162    /// The text to actually emit, regardless of variant.
163    pub fn into_text(self) -> String {
164        match self {
165            Enforced::Pass(t) => t,
166            Enforced::Redacted { text, .. } => text,
167            Enforced::Dropped { notice, .. } => notice,
168        }
169    }
170
171    /// True if the floor changed the content.
172    pub fn was_enforced(&self) -> bool {
173        !matches!(self, Enforced::Pass(_))
174    }
175}
176
177/// Apply the configured floor to a text item (e.g. a tool output).
178///
179/// `path` is an optional source hint used for path-based classification.
180/// Returns [`Enforced::Pass`] verbatim when disabled or below the floor.
181pub fn enforce_text(text: String, path: Option<&Path>, cfg: &SensitivityConfig) -> Enforced {
182    if !cfg.enabled_effective() {
183        return Enforced::Pass(text);
184    }
185    let level = classify(path, &text);
186    if level < cfg.policy_floor {
187        return Enforced::Pass(text);
188    }
189    match cfg.action {
190        FloorAction::Drop => {
191            let notice = format!(
192                "[lean-ctx: content withheld — sensitivity `{}` ≥ policy floor `{}`]",
193                level.as_str(),
194                cfg.policy_floor.as_str()
195            );
196            Enforced::Dropped { notice, level }
197        }
198        FloorAction::Redact => {
199            let redacted = classify::redact_sensitive(&text);
200            Enforced::Redacted {
201                text: redacted,
202                level,
203            }
204        }
205    }
206}
207
208/// Decide whether `fact_level` is blocked by the floor. Used for structured
209/// items (knowledge facts) where the level is known/stored rather than derived
210/// from free text. No-op (never blocked) when disabled.
211pub fn floor_blocks(fact_level: SensitivityLevel, cfg: &SensitivityConfig) -> bool {
212    cfg.enabled_effective() && fact_level >= cfg.policy_floor
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn levels_are_ordered() {
221        assert!(SensitivityLevel::Public < SensitivityLevel::Internal);
222        assert!(SensitivityLevel::Internal < SensitivityLevel::Confidential);
223        assert!(SensitivityLevel::Confidential < SensitivityLevel::Secret);
224    }
225
226    #[test]
227    fn parse_is_tolerant() {
228        assert_eq!(
229            SensitivityLevel::parse("SECRET"),
230            Some(SensitivityLevel::Secret)
231        );
232        assert_eq!(
233            SensitivityLevel::parse("pii"),
234            Some(SensitivityLevel::Confidential)
235        );
236        assert_eq!(SensitivityLevel::parse(""), Some(SensitivityLevel::Public));
237        assert_eq!(SensitivityLevel::parse("nope"), None);
238    }
239
240    #[test]
241    fn disabled_is_noop_even_for_secrets() {
242        let cfg = SensitivityConfig::default(); // enabled = false
243        let secret = "token = ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".to_string();
244        let out = enforce_text(secret.clone(), None, &cfg);
245        assert_eq!(out, Enforced::Pass(secret));
246    }
247
248    #[test]
249    fn below_floor_passes_unchanged() {
250        let cfg = SensitivityConfig {
251            enabled: true,
252            policy_floor: SensitivityLevel::Secret,
253            action: FloorAction::Redact,
254        };
255        let benign = "just a normal log line with no secrets".to_string();
256        assert_eq!(
257            enforce_text(benign.clone(), None, &cfg),
258            Enforced::Pass(benign)
259        );
260    }
261
262    #[test]
263    fn drop_action_withholds_secret() {
264        let cfg = SensitivityConfig {
265            enabled: true,
266            policy_floor: SensitivityLevel::Secret,
267            action: FloorAction::Drop,
268        };
269        let secret = "AWS key AKIAIOSFODNN7EXAMPLE leaked".to_string();
270        match enforce_text(secret, None, &cfg) {
271            Enforced::Dropped { level, notice } => {
272                assert_eq!(level, SensitivityLevel::Secret);
273                assert!(notice.contains("withheld"));
274            }
275            other => panic!("expected Dropped, got {other:?}"),
276        }
277    }
278
279    #[test]
280    fn redact_action_masks_secret_keeps_rest() {
281        let cfg = SensitivityConfig {
282            enabled: true,
283            policy_floor: SensitivityLevel::Secret,
284            action: FloorAction::Redact,
285        };
286        let text = "prefix AKIAIOSFODNN7EXAMPLE suffix".to_string();
287        match enforce_text(text, None, &cfg) {
288            Enforced::Redacted { text, level } => {
289                assert_eq!(level, SensitivityLevel::Secret);
290                assert!(text.contains("prefix"));
291                assert!(text.contains("suffix"));
292                assert!(!text.contains("AKIAIOSFODNN7EXAMPLE"));
293            }
294            other => panic!("expected Redacted, got {other:?}"),
295        }
296    }
297
298    #[test]
299    fn persona_floor_public_is_a_noop() {
300        // The coding default ("public") must never flip enforcement on.
301        let cfg = SensitivityConfig::default().with_persona_floor(SensitivityLevel::Public);
302        assert_eq!(cfg, SensitivityConfig::default());
303    }
304
305    #[test]
306    fn persona_floor_enables_enforcement_when_config_is_off() {
307        // lead-gen declares "confidential" → PII protection out of the box.
308        let cfg = SensitivityConfig::default().with_persona_floor(SensitivityLevel::Confidential);
309        assert!(cfg.enabled);
310        assert_eq!(cfg.policy_floor, SensitivityLevel::Confidential);
311    }
312
313    #[test]
314    fn persona_floor_only_tightens_an_enabled_config() {
315        let base = SensitivityConfig {
316            enabled: true,
317            policy_floor: SensitivityLevel::Secret,
318            action: FloorAction::Redact,
319        };
320        // Persona floor below the configured floor → tightened (min wins).
321        let tightened = base.clone().with_persona_floor(SensitivityLevel::Internal);
322        assert_eq!(tightened.policy_floor, SensitivityLevel::Internal);
323        // Config already stricter than the persona → unchanged.
324        let strict = SensitivityConfig {
325            enabled: true,
326            policy_floor: SensitivityLevel::Internal,
327            action: FloorAction::Redact,
328        };
329        let kept = strict
330            .clone()
331            .with_persona_floor(SensitivityLevel::Confidential);
332        assert_eq!(kept.policy_floor, SensitivityLevel::Internal);
333    }
334
335    #[test]
336    fn floor_blocks_respects_level_and_enabled() {
337        let mut cfg = SensitivityConfig {
338            enabled: true,
339            policy_floor: SensitivityLevel::Confidential,
340            action: FloorAction::Drop,
341        };
342        assert!(floor_blocks(SensitivityLevel::Secret, &cfg));
343        assert!(floor_blocks(SensitivityLevel::Confidential, &cfg));
344        assert!(!floor_blocks(SensitivityLevel::Internal, &cfg));
345        cfg.enabled = false;
346        assert!(!floor_blocks(SensitivityLevel::Secret, &cfg));
347    }
348}