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
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum Enforced {
127 Pass(String),
129 Redacted {
131 text: String,
132 level: SensitivityLevel,
133 },
134 Dropped {
136 notice: String,
137 level: SensitivityLevel,
138 },
139}
140
141impl Enforced {
142 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 pub fn was_enforced(&self) -> bool {
153 !matches!(self, Enforced::Pass(_))
154 }
155}
156
157pub 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
188pub 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(); 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}