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
67impl 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
81#[serde(rename_all = "snake_case")]
82pub enum FloorAction {
83 #[default]
85 Redact,
86 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(default)]
106pub struct SensitivityConfig {
107 pub enabled: bool,
109 pub policy_floor: SensitivityLevel,
111 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum Enforced {
159 Pass(String),
161 Redacted {
163 text: String,
164 level: SensitivityLevel,
165 },
166 Dropped {
168 notice: String,
169 level: SensitivityLevel,
170 },
171}
172
173impl Enforced {
174 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 pub fn was_enforced(&self) -> bool {
185 !matches!(self, Enforced::Pass(_))
186 }
187}
188
189pub 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
220pub 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(); 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 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 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 let tightened = base.clone().with_persona_floor(SensitivityLevel::Internal);
352 assert_eq!(tightened.policy_floor, SensitivityLevel::Internal);
353 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}