openlatch-client 0.5.2

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Privacy scrubbing for an outgoing `$exception`.
//!
//! The module this replaces was an adapter over `crate::privacy::PrivacyFilter` shaped to
//! the previous reporter's event type. The **filter** is what was worth keeping and is
//! untouched; only
//! the adapter is rewritten, against our own payload types.

use super::payload::Exception;
use crate::privacy::{filter_value, PrivacyFilter};

/// Scrub an outgoing exception in place.
///
/// Fields scrubbed, and why each:
///   - `Exception.value` — the panic message, the most likely place a path or token lands
///   - `Frame.function` — symbol names can embed string literals in some codegen paths
///   - `Frame.filename` — absolute paths carry usernames and machine names
///   - `Frame.module` — today a bare file name, but it is derived from a path that may
///     sit under a user's home directory, so it is scrubbed rather than trusted
///
/// NOT scrubbed, deliberately: `instruction_addr` / `image_addr` / `debug_id` /
/// `image_size`. They are hex or integers, they carry nothing, and running a token regex
/// over them risks rewriting a value symbolication depends on.
///
/// Never drops: an opaque redacted stack trace still dedupes by fingerprint and is still
/// useful for triage. This mirrors the behaviour of the module it replaces.
///
/// `Frame.vars` has no counterpart in our payload, so the note the old adapter carried
/// about not scrubbing it does not carry over.
pub fn scrub_exception(exc: &mut Exception, filter: &PrivacyFilter) {
    exc.value = scrub_string(&exc.value, filter);
    for frame in exc.stacktrace.frames_mut() {
        scrub_opt(&mut frame.function, filter);
        scrub_opt(&mut frame.filename, filter);
        scrub_opt(&mut frame.module, filter);
    }
}

/// Scrub an optional field in place, leaving an absent one absent.
fn scrub_opt(field: &mut Option<String>, filter: &PrivacyFilter) {
    if let Some(value) = field {
        *value = scrub_string(value, filter);
    }
}

/// Run the JSON-oriented privacy filter against a bare `String`.
///
/// The privacy module operates on `serde_json::Value::String`, so we wrap the input,
/// filter it, and unwrap. This is the documented way to reach the filter from a bare
/// string and it is what the module this replaces did.
fn scrub_string(s: &str, filter: &PrivacyFilter) -> String {
    let mut v = serde_json::Value::String(s.to_string());
    filter_value(&mut v, filter);
    match v {
        serde_json::Value::String(out) => out,
        _ => s.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::telemetry::crash::payload::{Frame, Mechanism, Stacktrace};

    fn filter() -> PrivacyFilter {
        PrivacyFilter::new(&[])
    }

    fn exception(message: &str, frames: Vec<Frame>) -> Exception {
        Exception {
            exception_type: "panic".into(),
            value: message.to_string(),
            mechanism: Some(Mechanism::panic()),
            thread_id: None,
            stacktrace: Stacktrace::Raw { frames },
        }
    }

    #[test]
    fn a_credential_in_the_panic_message_is_redacted() {
        // Synthetic pattern-matching fixture, not a real credential.
        let fake_aws = format!("{}{}", "AKIA", "1234567890ABCDEF"); // gitleaks:allow
        let mut exc = exception(&format!("boom: {fake_aws} leaked"), vec![]);
        scrub_exception(&mut exc, &filter());
        assert!(
            exc.value.contains("[AWS_KEY:AKIA***]"),
            "got: {}",
            exc.value
        );
        assert!(!exc.value.contains(&fake_aws));
    }

    #[test]
    fn a_bearer_token_in_the_panic_message_is_redacted() {
        let mut exc = exception(
            "auth failed for Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig",
            vec![],
        );
        scrub_exception(&mut exc, &filter());
        assert!(
            !exc.value.contains("eyJhbGciOiJIUzI1NiJ9.payload.sig"),
            "got: {}",
            exc.value
        );
    }

    #[test]
    fn frame_filename_function_and_module_are_all_scrubbed() {
        let mut frame = Frame::address_only(Some("0x1000".into()), Some("0x1000".into()));
        frame.filename = Some("/tmp/ghp_abcdefghijklmnopqrstuvwxyz0123456789/main.rs".into());
        frame.function = Some("boom_ghp_abcdefghijklmnopqrstuvwxyz0123456789".into());
        frame.module = Some("ghp_abcdefghijklmnopqrstuvwxyz0123456789".into());

        let mut exc = exception("boom", vec![frame]);
        scrub_exception(&mut exc, &filter());

        let scrubbed = &exc.stacktrace.frames()[0];
        for (label, value) in [
            ("filename", scrubbed.filename.as_deref()),
            ("function", scrubbed.function.as_deref()),
            ("module", scrubbed.module.as_deref()),
        ] {
            let value = value.unwrap_or_default();
            assert!(
                !value.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789"),
                "{label} still carries the token: {value}"
            );
        }
    }

    /// The regression guard that matters most: scrubbing a value symbolication depends on
    /// produces an event that never resolves and never errors.
    #[test]
    fn addresses_are_never_rewritten() {
        let mut frame =
            Frame::address_only(Some("0x7f3a9c041b2d".into()), Some("0x7f3a9c000000".into()));
        frame.filename = Some("/home/alice/src/main.rs".into());
        let mut exc = exception("boom", vec![frame]);
        scrub_exception(&mut exc, &filter());

        let scrubbed = &exc.stacktrace.frames()[0];
        assert_eq!(scrubbed.instruction_addr.as_deref(), Some("0x7f3a9c041b2d"));
        assert_eq!(scrubbed.image_addr.as_deref(), Some("0x7f3a9c000000"));
    }

    #[test]
    fn an_ordinary_panic_survives_unchanged() {
        let mut exc = exception("ordinary panic with no secrets", vec![]);
        scrub_exception(&mut exc, &filter());
        assert_eq!(exc.value, "ordinary panic with no secrets");
    }

    /// An exception is redacted, never dropped — an opaque trace still dedupes.
    #[test]
    fn scrubbing_never_empties_the_frame_list() {
        let frames = vec![
            Frame::address_only(Some("0x1".into()), None),
            Frame::address_only(Some("0x2".into()), None),
        ];
        let mut exc = exception("boom", frames);
        scrub_exception(&mut exc, &filter());
        assert_eq!(exc.stacktrace.frames().len(), 2);
    }
}