lean_ctx/core/sensitivity/
mod.rs1mod classify;
18
19pub use classify::{classify, classify_content, classify_path};
20
21use serde::{Deserialize, Serialize};
22use std::path::Path;
23
24#[derive(
29 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default, Hash,
30)]
31#[serde(rename_all = "snake_case")]
32pub enum SensitivityLevel {
33 #[default]
35 Public,
36 Internal,
38 Confidential,
40 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
69#[serde(rename_all = "snake_case")]
70pub enum FloorAction {
71 #[default]
73 Redact,
74 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(default)]
94pub struct SensitivityConfig {
95 pub enabled: bool,
97 pub policy_floor: SensitivityLevel,
99 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum Enforced {
147 Pass(String),
149 Redacted {
151 text: String,
152 level: SensitivityLevel,
153 },
154 Dropped {
156 notice: String,
157 level: SensitivityLevel,
158 },
159}
160
161impl Enforced {
162 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 pub fn was_enforced(&self) -> bool {
173 !matches!(self, Enforced::Pass(_))
174 }
175}
176
177pub 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
208pub 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(); 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 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 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 let tightened = base.clone().with_persona_floor(SensitivityLevel::Internal);
322 assert_eq!(tightened.policy_floor, SensitivityLevel::Internal);
323 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}