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
124/// Outcome of enforcing the floor on a single text item.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum Enforced {
127    /// Below floor (or disabled): returned unchanged.
128    Pass(String),
129    /// At/above floor with `Redact`: offending spans masked.
130    Redacted {
131        text: String,
132        level: SensitivityLevel,
133    },
134    /// At/above floor with `Drop`: replaced by a notice.
135    Dropped {
136        notice: String,
137        level: SensitivityLevel,
138    },
139}
140
141impl Enforced {
142    /// The text to actually emit, regardless of variant.
143    pub fn into_text(self) -> String {
144        match self {
145            Enforced::Pass(t) => t,
146            Enforced::Redacted { text, .. } => text,
147            Enforced::Dropped { notice, .. } => notice,
148        }
149    }
150
151    /// True if the floor changed the content.
152    pub fn was_enforced(&self) -> bool {
153        !matches!(self, Enforced::Pass(_))
154    }
155}
156
157/// Apply the configured floor to a text item (e.g. a tool output).
158///
159/// `path` is an optional source hint used for path-based classification.
160/// Returns [`Enforced::Pass`] verbatim when disabled or below the floor.
161pub fn enforce_text(text: String, path: Option<&Path>, cfg: &SensitivityConfig) -> Enforced {
162    if !cfg.enabled_effective() {
163        return Enforced::Pass(text);
164    }
165    let level = classify(path, &text);
166    if level < cfg.policy_floor {
167        return Enforced::Pass(text);
168    }
169    match cfg.action {
170        FloorAction::Drop => {
171            let notice = format!(
172                "[lean-ctx: content withheld — sensitivity `{}` ≥ policy floor `{}`]",
173                level.as_str(),
174                cfg.policy_floor.as_str()
175            );
176            Enforced::Dropped { notice, level }
177        }
178        FloorAction::Redact => {
179            let redacted = classify::redact_sensitive(&text);
180            Enforced::Redacted {
181                text: redacted,
182                level,
183            }
184        }
185    }
186}
187
188/// Decide whether `fact_level` is blocked by the floor. Used for structured
189/// items (knowledge facts) where the level is known/stored rather than derived
190/// from free text. No-op (never blocked) when disabled.
191pub fn floor_blocks(fact_level: SensitivityLevel, cfg: &SensitivityConfig) -> bool {
192    cfg.enabled_effective() && fact_level >= cfg.policy_floor
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn levels_are_ordered() {
201        assert!(SensitivityLevel::Public < SensitivityLevel::Internal);
202        assert!(SensitivityLevel::Internal < SensitivityLevel::Confidential);
203        assert!(SensitivityLevel::Confidential < SensitivityLevel::Secret);
204    }
205
206    #[test]
207    fn parse_is_tolerant() {
208        assert_eq!(
209            SensitivityLevel::parse("SECRET"),
210            Some(SensitivityLevel::Secret)
211        );
212        assert_eq!(
213            SensitivityLevel::parse("pii"),
214            Some(SensitivityLevel::Confidential)
215        );
216        assert_eq!(SensitivityLevel::parse(""), Some(SensitivityLevel::Public));
217        assert_eq!(SensitivityLevel::parse("nope"), None);
218    }
219
220    #[test]
221    fn disabled_is_noop_even_for_secrets() {
222        let cfg = SensitivityConfig::default(); // enabled = false
223        let secret = "token = ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".to_string();
224        let out = enforce_text(secret.clone(), None, &cfg);
225        assert_eq!(out, Enforced::Pass(secret));
226    }
227
228    #[test]
229    fn below_floor_passes_unchanged() {
230        let cfg = SensitivityConfig {
231            enabled: true,
232            policy_floor: SensitivityLevel::Secret,
233            action: FloorAction::Redact,
234        };
235        let benign = "just a normal log line with no secrets".to_string();
236        assert_eq!(
237            enforce_text(benign.clone(), None, &cfg),
238            Enforced::Pass(benign)
239        );
240    }
241
242    #[test]
243    fn drop_action_withholds_secret() {
244        let cfg = SensitivityConfig {
245            enabled: true,
246            policy_floor: SensitivityLevel::Secret,
247            action: FloorAction::Drop,
248        };
249        let secret = "AWS key AKIAIOSFODNN7EXAMPLE leaked".to_string();
250        match enforce_text(secret, None, &cfg) {
251            Enforced::Dropped { level, notice } => {
252                assert_eq!(level, SensitivityLevel::Secret);
253                assert!(notice.contains("withheld"));
254            }
255            other => panic!("expected Dropped, got {other:?}"),
256        }
257    }
258
259    #[test]
260    fn redact_action_masks_secret_keeps_rest() {
261        let cfg = SensitivityConfig {
262            enabled: true,
263            policy_floor: SensitivityLevel::Secret,
264            action: FloorAction::Redact,
265        };
266        let text = "prefix AKIAIOSFODNN7EXAMPLE suffix".to_string();
267        match enforce_text(text, None, &cfg) {
268            Enforced::Redacted { text, level } => {
269                assert_eq!(level, SensitivityLevel::Secret);
270                assert!(text.contains("prefix"));
271                assert!(text.contains("suffix"));
272                assert!(!text.contains("AKIAIOSFODNN7EXAMPLE"));
273            }
274            other => panic!("expected Redacted, got {other:?}"),
275        }
276    }
277
278    #[test]
279    fn floor_blocks_respects_level_and_enabled() {
280        let mut cfg = SensitivityConfig {
281            enabled: true,
282            policy_floor: SensitivityLevel::Confidential,
283            action: FloorAction::Drop,
284        };
285        assert!(floor_blocks(SensitivityLevel::Secret, &cfg));
286        assert!(floor_blocks(SensitivityLevel::Confidential, &cfg));
287        assert!(!floor_blocks(SensitivityLevel::Internal, &cfg));
288        cfg.enabled = false;
289        assert!(!floor_blocks(SensitivityLevel::Secret, &cfg));
290    }
291}