klieo-core 3.0.0

Core traits + runtime for the klieo agent framework.
Documentation
//! Audit-redaction port and the fail-closed opaque-digest fallback.
//!
//! Tools that handle claimant PII flag themselves via
//! [`crate::Tool::redacts_audit`]; dispatch then records a redacted
//! projection of their input/output into [`crate::EpisodicMemory`]
//! instead of the raw bytes, so PII never lands plaintext in the
//! durable audit trail. The concrete redactor lives in `klieo-ops`;
//! `klieo-core` only defines the injectable port here.

use sha2::{Digest, Sha256};

const DIGEST_FIELD: &str = "digest";
const SCHEME_FIELD: &str = "redacted";
const SCHEME_SHA256: &str = "sha256";

/// Injectable redaction port.
///
/// An implementation transforms a value into a redacted projection that
/// preserves enough shape for audit usefulness while guaranteeing no
/// PII survives. Implementations MUST NOT return the input unchanged for
/// any value containing sensitive data. The real masking redactor is
/// provided by `klieo-ops`; `klieo-core` cannot depend on it, so it
/// accepts this port and falls back to [`opaque_digest`] when no
/// redactor is wired.
pub trait AuditRedactor: Send + Sync {
    /// Return a redacted projection of `value` safe to persist in the
    /// durable audit trail.
    fn redact(&self, value: &serde_json::Value) -> serde_json::Value;
}

/// Fail-closed total-opacity fallback used when no [`AuditRedactor`] is
/// wired on a redaction-flagged tool.
///
/// Returns `{"redacted":"sha256","digest":"<hex>"}` where the digest is
/// SHA-256 over a canonical serialization of `value`. The digest is
/// deterministic: equal values yield equal digests and distinct values
/// yield distinct digests with overwhelming probability. No part of the
/// input is recoverable from the result, so this never leaks PII even
/// when the masking redactor is absent.
///
/// Canonicalization: keys are sorted explicitly via [`canonicalize`]
/// before hashing, so the digest is invariant to object key order even
/// when a downstream enables `serde_json`'s `preserve_order` feature
/// (which makes `Map` an insertion-ordered `IndexMap` rather than a
/// sorted `BTreeMap`).
pub fn opaque_digest(value: &serde_json::Value) -> serde_json::Value {
    let canonical = serde_json::to_vec(&canonicalize(value))
        .expect("a serde_json::Value is always serializable");
    let hex = hex_lower(&Sha256::digest(&canonical));
    serde_json::json!({
        SCHEME_FIELD: SCHEME_SHA256,
        DIGEST_FIELD: hex,
    })
}

/// Key-order canonicalization for [`opaque_digest`]. Without it, enabling
/// `serde_json/preserve_order` anywhere in the dependency graph (object
/// `Map` becomes an insertion-ordered `IndexMap`) would let the digest
/// vary with object key order, breaking its order-invariance contract.
fn canonicalize(value: &serde_json::Value) -> serde_json::Value {
    use serde_json::Value;
    match value {
        Value::Object(map) => {
            let mut entries: Vec<(&String, &Value)> = map.iter().collect();
            entries.sort_by(|a, b| a.0.cmp(b.0));
            let mut sorted = serde_json::Map::with_capacity(entries.len());
            for (key, child) in entries {
                sorted.insert(key.clone(), canonicalize(child));
            }
            Value::Object(sorted)
        }
        Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
        other => other.clone(),
    }
}

fn hex_lower(bytes: &[u8]) -> String {
    use std::fmt::Write;
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        let _ = write!(out, "{byte:02x}");
    }
    out
}

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

    #[test]
    fn opaque_digest_is_deterministic_for_equal_input() {
        let value = serde_json::json!({"iban": "DE89", "email": "a@b.de"});
        assert_eq!(opaque_digest(&value), opaque_digest(&value));
    }

    #[test]
    fn opaque_digest_differs_for_distinct_input() {
        let a = serde_json::json!({"iban": "DE89"});
        let b = serde_json::json!({"iban": "DE90"});
        assert_ne!(opaque_digest(&a), opaque_digest(&b));
    }

    #[test]
    fn opaque_digest_carries_scheme_marker_and_hex_digest() {
        let out = opaque_digest(&serde_json::json!("secret"));
        assert_eq!(out[SCHEME_FIELD], serde_json::json!(SCHEME_SHA256));
        let digest = out[DIGEST_FIELD].as_str().expect("digest is a string");
        assert_eq!(digest.len(), 64, "sha256 hex is 64 chars");
        assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
    }

    #[test]
    fn opaque_digest_does_not_echo_input_bytes() {
        let iban = "DE89370400440532013000";
        let out = opaque_digest(&serde_json::json!({"iban": iban}));
        assert!(!serde_json::to_string(&out).unwrap().contains(iban));
    }

    #[test]
    fn opaque_digest_is_key_order_invariant() {
        let a = serde_json::json!({"x": 1, "y": 2});
        let b = serde_json::json!({"y": 2, "x": 1});
        assert_eq!(opaque_digest(&a), opaque_digest(&b));
    }

    #[test]
    fn opaque_digest_is_key_order_invariant_when_nested() {
        let a = serde_json::json!({"outer": {"x": 1, "y": 2}, "list": [{"a": 1, "b": 2}]});
        let b = serde_json::json!({"list": [{"b": 2, "a": 1}], "outer": {"y": 2, "x": 1}});
        assert_eq!(opaque_digest(&a), opaque_digest(&b));
    }
}