mod classify;
pub use classify::{classify, classify_content, classify_path};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default, Hash,
)]
#[serde(rename_all = "snake_case")]
pub enum SensitivityLevel {
#[default]
Public,
Internal,
Confidential,
Secret,
}
impl SensitivityLevel {
pub fn as_str(self) -> &'static str {
match self {
SensitivityLevel::Public => "public",
SensitivityLevel::Internal => "internal",
SensitivityLevel::Confidential => "confidential",
SensitivityLevel::Secret => "secret",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"public" | "none" | "" => Some(SensitivityLevel::Public),
"internal" => Some(SensitivityLevel::Internal),
"confidential" | "pii" => Some(SensitivityLevel::Confidential),
"secret" | "secrets" | "credential" | "credentials" => Some(SensitivityLevel::Secret),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, Hash)]
#[serde(rename_all = "snake_case")]
pub enum FloorAction {
#[default]
Redact,
Drop,
}
impl FloorAction {
pub fn as_str(self) -> &'static str {
match self {
FloorAction::Redact => "redact",
FloorAction::Drop => "drop",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SensitivityConfig {
pub enabled: bool,
pub policy_floor: SensitivityLevel,
pub action: FloorAction,
}
impl Default for SensitivityConfig {
fn default() -> Self {
Self {
enabled: false,
policy_floor: SensitivityLevel::Secret,
action: FloorAction::Redact,
}
}
}
impl SensitivityConfig {
pub fn enabled_effective(&self) -> bool {
if let Ok(v) = std::env::var("LEAN_CTX_SENSITIVITY") {
return !matches!(v.trim(), "0" | "false" | "off");
}
self.enabled
}
#[must_use]
pub fn with_persona_floor(mut self, floor: SensitivityLevel) -> Self {
if floor > SensitivityLevel::Public {
if self.enabled {
self.policy_floor = self.policy_floor.min(floor);
} else {
self.enabled = true;
self.policy_floor = floor;
}
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Enforced {
Pass(String),
Redacted {
text: String,
level: SensitivityLevel,
},
Dropped {
notice: String,
level: SensitivityLevel,
},
}
impl Enforced {
pub fn into_text(self) -> String {
match self {
Enforced::Pass(t) => t,
Enforced::Redacted { text, .. } => text,
Enforced::Dropped { notice, .. } => notice,
}
}
pub fn was_enforced(&self) -> bool {
!matches!(self, Enforced::Pass(_))
}
}
pub fn enforce_text(text: String, path: Option<&Path>, cfg: &SensitivityConfig) -> Enforced {
if !cfg.enabled_effective() {
return Enforced::Pass(text);
}
let level = classify(path, &text);
if level < cfg.policy_floor {
return Enforced::Pass(text);
}
match cfg.action {
FloorAction::Drop => {
let notice = format!(
"[lean-ctx: content withheld — sensitivity `{}` ≥ policy floor `{}`]",
level.as_str(),
cfg.policy_floor.as_str()
);
Enforced::Dropped { notice, level }
}
FloorAction::Redact => {
let redacted = classify::redact_sensitive(&text);
Enforced::Redacted {
text: redacted,
level,
}
}
}
}
pub fn floor_blocks(fact_level: SensitivityLevel, cfg: &SensitivityConfig) -> bool {
cfg.enabled_effective() && fact_level >= cfg.policy_floor
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levels_are_ordered() {
assert!(SensitivityLevel::Public < SensitivityLevel::Internal);
assert!(SensitivityLevel::Internal < SensitivityLevel::Confidential);
assert!(SensitivityLevel::Confidential < SensitivityLevel::Secret);
}
#[test]
fn parse_is_tolerant() {
assert_eq!(
SensitivityLevel::parse("SECRET"),
Some(SensitivityLevel::Secret)
);
assert_eq!(
SensitivityLevel::parse("pii"),
Some(SensitivityLevel::Confidential)
);
assert_eq!(SensitivityLevel::parse(""), Some(SensitivityLevel::Public));
assert_eq!(SensitivityLevel::parse("nope"), None);
}
#[test]
fn disabled_is_noop_even_for_secrets() {
let cfg = SensitivityConfig::default(); let secret = "token = ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".to_string();
let out = enforce_text(secret.clone(), None, &cfg);
assert_eq!(out, Enforced::Pass(secret));
}
#[test]
fn below_floor_passes_unchanged() {
let cfg = SensitivityConfig {
enabled: true,
policy_floor: SensitivityLevel::Secret,
action: FloorAction::Redact,
};
let benign = "just a normal log line with no secrets".to_string();
assert_eq!(
enforce_text(benign.clone(), None, &cfg),
Enforced::Pass(benign)
);
}
#[test]
fn drop_action_withholds_secret() {
let cfg = SensitivityConfig {
enabled: true,
policy_floor: SensitivityLevel::Secret,
action: FloorAction::Drop,
};
let secret = "AWS key AKIAIOSFODNN7EXAMPLE leaked".to_string();
match enforce_text(secret, None, &cfg) {
Enforced::Dropped { level, notice } => {
assert_eq!(level, SensitivityLevel::Secret);
assert!(notice.contains("withheld"));
}
other => panic!("expected Dropped, got {other:?}"),
}
}
#[test]
fn redact_action_masks_secret_keeps_rest() {
let cfg = SensitivityConfig {
enabled: true,
policy_floor: SensitivityLevel::Secret,
action: FloorAction::Redact,
};
let text = "prefix AKIAIOSFODNN7EXAMPLE suffix".to_string();
match enforce_text(text, None, &cfg) {
Enforced::Redacted { text, level } => {
assert_eq!(level, SensitivityLevel::Secret);
assert!(text.contains("prefix"));
assert!(text.contains("suffix"));
assert!(!text.contains("AKIAIOSFODNN7EXAMPLE"));
}
other => panic!("expected Redacted, got {other:?}"),
}
}
#[test]
fn persona_floor_public_is_a_noop() {
let cfg = SensitivityConfig::default().with_persona_floor(SensitivityLevel::Public);
assert_eq!(cfg, SensitivityConfig::default());
}
#[test]
fn persona_floor_enables_enforcement_when_config_is_off() {
let cfg = SensitivityConfig::default().with_persona_floor(SensitivityLevel::Confidential);
assert!(cfg.enabled);
assert_eq!(cfg.policy_floor, SensitivityLevel::Confidential);
}
#[test]
fn persona_floor_only_tightens_an_enabled_config() {
let base = SensitivityConfig {
enabled: true,
policy_floor: SensitivityLevel::Secret,
action: FloorAction::Redact,
};
let tightened = base.clone().with_persona_floor(SensitivityLevel::Internal);
assert_eq!(tightened.policy_floor, SensitivityLevel::Internal);
let strict = SensitivityConfig {
enabled: true,
policy_floor: SensitivityLevel::Internal,
action: FloorAction::Redact,
};
let kept = strict
.clone()
.with_persona_floor(SensitivityLevel::Confidential);
assert_eq!(kept.policy_floor, SensitivityLevel::Internal);
}
#[test]
fn floor_blocks_respects_level_and_enabled() {
let mut cfg = SensitivityConfig {
enabled: true,
policy_floor: SensitivityLevel::Confidential,
action: FloorAction::Drop,
};
assert!(floor_blocks(SensitivityLevel::Secret, &cfg));
assert!(floor_blocks(SensitivityLevel::Confidential, &cfg));
assert!(!floor_blocks(SensitivityLevel::Internal, &cfg));
cfg.enabled = false;
assert!(!floor_blocks(SensitivityLevel::Secret, &cfg));
}
}