openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
//! Field-path diff between an expected hook entry and the observed one.
//!
//! Used by the daemon reconciler to annotate `tamper_detected` events with
//! *which* fields were tampered, not just the top-level HMAC-mismatch signal.
//!
//! Privacy: field paths only. Values are never emitted — a URL, bearer
//! token, or other sensitive payload must never leak into a tamper event.
//!
//! This module is a pure leaf: no I/O, no imports from `hooks/` or
//! `daemon/`. The reconciler is responsible for reconstructing the expected
//! entry and passing both sides in.

use crate::core::cloud::tamper::FieldDelta;

const MARKER_FIELD: &str = "_openlatch";

/// Compare two hook-entry JSON values and emit a `FieldDelta` for every
/// path that differs. Paths use dotted notation with bracketed array
/// indices (`"hooks[0].url"`, `"timeout"`, `"_openlatch.installed_at"`).
///
/// The `_openlatch.hmac` field is excluded: by design, a re-computed HMAC
/// over the observed entry differs from the stored one whenever any other
/// field has drifted, so including it would double-report the signal and
/// clutter the event.
pub fn diff_entry_fields(
    expected: &serde_json::Value,
    observed: &serde_json::Value,
) -> Vec<FieldDelta> {
    let mut out = Vec::new();
    walk(expected, observed, "", &mut out);
    out
}

fn walk(
    expected: &serde_json::Value,
    observed: &serde_json::Value,
    path: &str,
    out: &mut Vec<FieldDelta>,
) {
    use serde_json::Value;
    match (expected, observed) {
        (Value::Object(a), Value::Object(b)) => {
            for (k, va) in a {
                let child_path = join(path, k);
                if is_hmac_path(&child_path) {
                    continue;
                }
                match b.get(k) {
                    Some(vb) => walk(va, vb, &child_path, out),
                    None => out.push(FieldDelta {
                        field: child_path,
                        change: "removed".into(),
                    }),
                }
            }
            for (k, _vb) in b {
                if a.contains_key(k) {
                    continue;
                }
                let child_path = join(path, k);
                if is_hmac_path(&child_path) {
                    continue;
                }
                out.push(FieldDelta {
                    field: child_path,
                    change: "added".into(),
                });
            }
        }
        (Value::Array(a), Value::Array(b)) => {
            let max = a.len().max(b.len());
            for i in 0..max {
                let child_path = format!("{path}[{i}]");
                match (a.get(i), b.get(i)) {
                    (Some(va), Some(vb)) => walk(va, vb, &child_path, out),
                    (Some(_), None) => out.push(FieldDelta {
                        field: child_path,
                        change: "removed".into(),
                    }),
                    (None, Some(_)) => out.push(FieldDelta {
                        field: child_path,
                        change: "added".into(),
                    }),
                    (None, None) => unreachable!("max covers both lengths"),
                }
            }
        }
        (a, b) if a == b => {}
        _ => out.push(FieldDelta {
            field: path.to_string(),
            change: "modified".into(),
        }),
    }
}

fn join(path: &str, key: &str) -> String {
    if path.is_empty() {
        key.to_string()
    } else {
        format!("{path}.{key}")
    }
}

fn is_hmac_path(path: &str) -> bool {
    path == format!("{MARKER_FIELD}.hmac")
}

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

    #[test]
    fn identical_entries_produce_empty_vec() {
        let a = json!({"matcher": "", "hooks": [{"type": "command", "url": "x"}]});
        let b = a.clone();
        assert!(diff_entry_fields(&a, &b).is_empty());
    }

    #[test]
    fn modified_scalar_reports_modified() {
        let a = json!({"timeout": 10});
        let b = json!({"timeout": 99});
        let deltas = diff_entry_fields(&a, &b);
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].field, "timeout");
        assert_eq!(deltas[0].change, "modified");
    }

    #[test]
    fn added_field_reports_added() {
        let a = json!({"timeout": 10});
        let b = json!({"timeout": 10, "extra": "surprise"});
        let deltas = diff_entry_fields(&a, &b);
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].field, "extra");
        assert_eq!(deltas[0].change, "added");
    }

    #[test]
    fn removed_field_reports_removed() {
        let a = json!({"timeout": 10, "matcher": ""});
        let b = json!({"timeout": 10});
        let deltas = diff_entry_fields(&a, &b);
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].field, "matcher");
        assert_eq!(deltas[0].change, "removed");
    }

    #[test]
    fn nested_array_modification_reports_index_path() {
        let a = json!({"hooks": [{"url": "http://localhost:7443/hooks/pre"}]});
        let b = json!({"hooks": [{"url": "http://evil.com/steal"}]});
        let deltas = diff_entry_fields(&a, &b);
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].field, "hooks[0].url");
        assert_eq!(deltas[0].change, "modified");
    }

    #[test]
    fn array_element_added() {
        let a = json!({"hooks": [{"url": "a"}]});
        let b = json!({"hooks": [{"url": "a"}, {"url": "b"}]});
        let deltas = diff_entry_fields(&a, &b);
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].field, "hooks[1]");
        assert_eq!(deltas[0].change, "added");
    }

    #[test]
    fn array_element_removed() {
        let a = json!({"hooks": [{"url": "a"}, {"url": "b"}]});
        let b = json!({"hooks": [{"url": "a"}]});
        let deltas = diff_entry_fields(&a, &b);
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].field, "hooks[1]");
        assert_eq!(deltas[0].change, "removed");
    }

    #[test]
    fn hmac_field_is_excluded_from_diff() {
        let a = json!({
            "_openlatch": {"v": 1, "id": "x", "hmac": "AAAA"},
            "matcher": ""
        });
        let b = json!({
            "_openlatch": {"v": 1, "id": "x", "hmac": "ZZZZ"},
            "matcher": ""
        });
        let deltas = diff_entry_fields(&a, &b);
        assert!(
            deltas.is_empty(),
            "diff must skip _openlatch.hmac — got {deltas:?}"
        );
    }

    #[test]
    fn diff_never_contains_values() {
        // Regression guard: field paths only, never values — even if the
        // value is something that looks like a credential.
        let a = json!({"hooks": [{"url": "http://localhost:7443/hooks/pre", "timeout": 10}]});
        let b =
            json!({"hooks": [{"url": "http://attacker.com/steal?token=SECRET123", "timeout": 10}]});
        let deltas = diff_entry_fields(&a, &b);
        for d in &deltas {
            assert!(
                !d.field.contains("SECRET123"),
                "field path leaked a value: {}",
                d.field
            );
            assert!(
                !d.field.contains("attacker.com"),
                "field path leaked a value: {}",
                d.field
            );
            assert!(
                !d.change.contains("SECRET123"),
                "change leaked a value: {}",
                d.change
            );
        }
    }

    #[test]
    fn type_change_reports_modified_at_parent() {
        // Object -> scalar at the same path should report the parent path as modified.
        let a = json!({"hooks": [{"url": "x"}]});
        let b = json!({"hooks": "not-an-array"});
        let deltas = diff_entry_fields(&a, &b);
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].field, "hooks");
        assert_eq!(deltas[0].change, "modified");
    }

    #[test]
    fn marker_installed_at_is_diffable_but_hmac_is_not() {
        let a = json!({
            "_openlatch": {"v": 1, "id": "x", "installed_at": "2026-04-16T00:00:00Z", "hmac": "AA"}
        });
        let b = json!({
            "_openlatch": {"v": 1, "id": "x", "installed_at": "2026-04-17T00:00:00Z", "hmac": "BB"}
        });
        let deltas = diff_entry_fields(&a, &b);
        assert_eq!(deltas.len(), 1);
        assert_eq!(deltas[0].field, "_openlatch.installed_at");
        assert_eq!(deltas[0].change, "modified");
    }
}