klieo-ops 3.5.0

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! Redaction trait + default impl.
//!
//! Every event is passed through a `Redactor` before persistence into
//! `EpisodicMemory`. The default impl handles common EU FSI PII shapes
//! (IBAN, BIC, email, German tax id, IPv4/v6, JWT, PEM private key).

use regex::{Regex, RegexBuilder};
use serde_json::Value;
use std::sync::Arc;

/// Trait for redaction of arbitrary JSON payloads.
///
/// Implementations are pure: no I/O, no panics, total over input.
pub trait Redactor: Send + Sync {
    /// Return a redacted clone of `v`.
    fn redact(&self, v: &Value) -> Value;
}

/// Default redactor covering common PII patterns for EU FSI.
pub struct DefaultRedactor {
    patterns: Vec<(&'static str, Regex)>,
}

impl Default for DefaultRedactor {
    fn default() -> Self {
        Self::new()
    }
}

impl DefaultRedactor {
    /// Build a new default redactor.
    #[must_use]
    pub fn new() -> Self {
        // Order matters: each pass operates on the output of the previous one.
        // BIC must precede IBAN so that the replacement token `[REDACTED:IBAN]`
        // cannot be re-matched by the BIC pattern (REDACTED ≅ 8-letter BIC).
        // Pattern catalogue is the single source of truth from klieo-pii-patterns.
        let patterns: Vec<(&'static str, Regex)> = klieo_pii_patterns::PATTERNS
            .iter()
            .map(|(label, src)| {
                let re = RegexBuilder::new(src)
                    .case_insensitive(true)
                    .build()
                    .unwrap_or_else(|e| {
                        panic!("klieo-pii-patterns {label} failed to compile: {e}")
                    });
                (*label, re)
            })
            .collect();
        Self { patterns }
    }

    fn redact_string(&self, s: &str) -> String {
        let mut out = s.to_string();
        for (label, re) in &self.patterns {
            out = re
                .replace_all(&out, format!("[REDACTED:{label}]").as_str())
                .into_owned();
        }
        out
    }
}

impl Redactor for DefaultRedactor {
    fn redact(&self, v: &Value) -> Value {
        match v {
            Value::String(s) => Value::String(self.redact_string(s)),
            Value::Array(xs) => Value::Array(xs.iter().map(|x| self.redact(x)).collect()),
            Value::Object(m) => {
                let mut out = serde_json::Map::with_capacity(m.len());
                for (k, val) in m {
                    out.insert(k.clone(), self.redact(val));
                }
                Value::Object(out)
            }
            _ => v.clone(),
        }
    }
}

impl klieo_core::redact::AuditRedactor for DefaultRedactor {
    fn redact(&self, v: &Value) -> Value {
        Redactor::redact(self, v)
    }
}

/// `Arc<dyn Redactor>` alias used inside `OpsRuntime`.
pub type SharedRedactor = Arc<dyn Redactor>;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_redactor_redacts_iban() {
        let r = DefaultRedactor::new();
        let v = serde_json::json!({"note": "send to DE89370400440532013000"});
        let out = r.redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(!s.contains("DE89370400440532013000"));
        assert!(s.contains("[REDACTED:IBAN]"));
    }

    #[test]
    fn default_redactor_redacts_email() {
        let r = DefaultRedactor::new();
        let v = serde_json::json!({"to": "alice@example.com"});
        let out = r.redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(!s.contains("alice@example.com"));
        assert!(s.contains("[REDACTED:EMAIL]"));
    }

    #[test]
    fn default_redactor_redacts_jwt() {
        let r = DefaultRedactor::new();
        // Build the JWT-shaped fixture at runtime so the literal does not
        // appear in source-control diffs and trip secret-scanners on the
        // pre-merge gate.
        let header = format!("ey{}", "JhbGciOiJIUzI1NiJ9");
        let payload = format!("ey{}", "JzdWIiOiIxMjMifQ");
        let jwt = format!("{header}.{payload}.abc");
        let v = serde_json::json!({ "auth": jwt });
        let out = r.redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(s.contains("[REDACTED:JWT]"));
    }

    #[test]
    fn default_redactor_preserves_clean_strings() {
        let r = DefaultRedactor::new();
        let v = serde_json::json!({"amount": 1234, "currency": "EUR"});
        let out = r.redact(&v);
        assert_eq!(out, v);
    }

    #[test]
    fn default_redactor_does_not_redact_ordinary_prose_as_bic() {
        // Regression: the redactor compiles patterns case-insensitively, which
        // previously made the BIC pattern match any 8/11-letter word and
        // littered free-text with [REDACTED:BIC]. Ordinary prose must survive.
        let r = DefaultRedactor::new();
        let v = serde_json::json!({
            "justification": "High-risk: the system potentially affects individuals' employment decisions."
        });
        let s = serde_json::to_string(&r.redact(&v)).unwrap();
        assert!(
            !s.contains("[REDACTED:BIC]"),
            "ordinary words must not redact as BIC: {s}"
        );
        assert!(
            s.contains("individuals"),
            "clean prose must be preserved verbatim: {s}"
        );
        // A genuine uppercase BIC must still be redacted.
        let s2 =
            serde_json::to_string(&r.redact(&serde_json::json!({"swift": "DEUTDEFF"}))).unwrap();
        assert!(
            s2.contains("[REDACTED:BIC]"),
            "valid BIC must still redact: {s2}"
        );
    }

    #[test]
    fn default_redactor_redacts_lowercase_iban() {
        let r = DefaultRedactor::new();
        let v = serde_json::json!({"note": "send to de89370400440532013000"});
        let out = r.redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(
            !s.contains("de89370400440532013000"),
            "lowercase IBAN must be redacted"
        );
        assert!(s.contains("[REDACTED:IBAN]"));
    }

    #[test]
    fn default_redactor_does_not_redact_bare_11_digits() {
        let r = DefaultRedactor::new();
        let v = serde_json::json!({"claim_id": "12345678901", "amount_cents": 12345678901u64});
        let out = r.redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(
            s.contains("12345678901"),
            "bare 11-digit values must NOT be redacted"
        );
        assert!(!s.contains("[REDACTED:TAXID_DE]"));
    }

    #[test]
    fn default_redactor_masks_pii_through_audit_redactor_trait_object() {
        let redactor: Arc<dyn klieo_core::AuditRedactor> = Arc::new(DefaultRedactor::new());
        let v = serde_json::json!({"note": "send to DE89370400440532013000"});
        let out = redactor.redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(
            !s.contains("DE89370400440532013000"),
            "PII must not survive the AuditRedactor path"
        );
        assert!(s.contains("[REDACTED:IBAN]"));
    }

    #[test]
    fn default_redactor_redacts_taxid_with_prefix() {
        let r = DefaultRedactor::new();
        let v = serde_json::json!({"note": "Steuer-ID: 12345678901"});
        let out = r.redact(&v);
        let s = serde_json::to_string(&out).unwrap();
        assert!(s.contains("[REDACTED:TAXID_DE]"));
        assert!(!s.contains("12345678901"));
    }
}