pub mod heal;
pub mod request;
use std::path::{Path, PathBuf};
use tokio::sync::mpsc;
use crate::cloud::tamper::{FieldDelta, TamperCloudEventKind, TamperEvent};
use crate::cloud::CloudEvent;
use crate::core::hook_state::diff::diff_entry_fields;
use crate::core::hook_state::hmac::verify_entry_hmac;
use crate::core::hook_state::key::HmacKeyStore;
use crate::core::hook_state::marker::{classify_marker, MarkerShape};
use crate::core::hook_state::{hash_settings_path, HookStateFile, StateEntry};
use crate::core::logging::tamper_log::TamperLogger;
use crate::core::telemetry::{self, Event};
use crate::hooks::jsonc;
use heal::HealManager;
use request::ReconcileRequest;
const AGENT_TYPE: &str = "claude-code";
fn current_os() -> &'static str {
if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "windows") {
"windows"
} else {
"linux"
}
}
#[derive(Clone, Default)]
pub struct TamperSinks {
pub logger: Option<TamperLogger>,
pub cloud_tx: Option<mpsc::Sender<CloudEvent>>,
pub agent_id: String,
pub client_version: String,
}
#[derive(Debug, Clone)]
enum DriftOutcome {
Healthy,
Drifted {
method: &'static str,
deltas: Vec<FieldDelta>,
},
}
pub struct Reconciler {
rx: mpsc::Receiver<ReconcileRequest>,
settings_path: PathBuf,
openlatch_dir: PathBuf,
port: u16,
token_file_path: PathBuf,
heal_manager: HealManager,
sinks: TamperSinks,
}
impl Reconciler {
pub fn new(
rx: mpsc::Receiver<ReconcileRequest>,
settings_path: PathBuf,
openlatch_dir: PathBuf,
port: u16,
token_file_path: PathBuf,
) -> Self {
Self::new_with_sinks(
rx,
settings_path,
openlatch_dir,
port,
token_file_path,
TamperSinks::default(),
)
}
pub fn new_with_sinks(
rx: mpsc::Receiver<ReconcileRequest>,
settings_path: PathBuf,
openlatch_dir: PathBuf,
port: u16,
token_file_path: PathBuf,
sinks: TamperSinks,
) -> Self {
Self {
rx,
settings_path,
openlatch_dir,
port,
token_file_path,
heal_manager: HealManager::new(),
sinks,
}
}
pub async fn run(mut self) {
tracing::info!("reconciler started");
while let Some(req) = self.rx.recv().await {
match req {
ReconcileRequest::Shutdown => {
tracing::info!("reconciler shutting down");
break;
}
ReconcileRequest::Fs | ReconcileRequest::Poll => {
self.reconcile().await;
}
}
}
}
pub fn reconcile_sync(&mut self) {
let rt = tokio::runtime::Handle::try_current();
if rt.is_ok() {
self.do_reconcile();
}
}
async fn reconcile(&mut self) {
self.do_reconcile();
}
fn do_reconcile(&mut self) {
let raw = match std::fs::read_to_string(&self.settings_path) {
Ok(c) => c,
Err(e) => {
tracing::debug!(error = %e, "reconciler: cannot read settings.json");
return;
}
};
let state = match HookStateFile::load(&self.openlatch_dir) {
Ok(Some(s)) => s,
Ok(None) => {
tracing::debug!("reconciler: no state file yet, skipping verification");
return;
}
Err(e) => {
tracing::warn!(error = %e, "reconciler: cannot load state file");
return;
}
};
let hmac_key = match HmacKeyStore::new(&self.openlatch_dir).load_or_create() {
Ok(k) => k,
Err(e) => {
tracing::warn!(error = %e, "reconciler: cannot load HMAC key");
return;
}
};
let parsed = match jsonc::parse_settings_value(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "reconciler: cannot parse settings.json");
return;
}
};
let hooks_obj = match parsed.get("hooks").and_then(|h| h.as_object()) {
Some(h) => h,
None => {
if !state.entries.is_empty() {
tracing::warn!(
"reconciler: hooks object missing from settings.json — will heal"
);
let _ = self.try_heal();
}
return;
}
};
let settings_path_hash = hash_settings_path(&self.settings_path);
let mut pending_detections: Vec<(StateEntry, TamperEvent)> = Vec::new();
let mut drift_results: Vec<(String, bool)> = Vec::new();
for state_entry in &state.entries {
if self.heal_manager.tracker(&state_entry.id).is_circuit_open() {
drift_results.push((state_entry.id.clone(), false));
continue;
}
let outcome = check_entry_drift(
&state_entry.hook_event,
&state_entry.id,
hooks_obj,
&hmac_key,
self.port,
);
let drifted = matches!(outcome, DriftOutcome::Drifted { .. });
drift_results.push((state_entry.id.clone(), drifted));
if let DriftOutcome::Drifted { method, deltas } = outcome {
let event = TamperEvent::new(
state_entry.id.clone(),
AGENT_TYPE.to_string(),
settings_path_hash.clone(),
state_entry.hook_event.clone(),
method.to_string(),
)
.with_field_deltas(deltas);
self.publish_detected(&event);
pending_detections.push((state_entry.clone(), event));
}
}
let mut any_drift = false;
for (entry_id, drifted) in &drift_results {
let tracker = self.heal_manager.tracker(entry_id);
if *drifted {
tracker.mark_drifted();
any_drift = true;
} else {
tracker.mark_healthy();
}
}
if any_drift {
for (entry_id, _) in &drift_results {
let tracker = self.heal_manager.tracker(entry_id);
if tracker.should_heal() {
tracker.record_heal_attempt();
}
}
let heal_ok = self.try_heal().is_ok();
for (entry_id, _) in &drift_results {
let tracker = self.heal_manager.tracker(entry_id);
if matches!(tracker.state, heal::HealState::Healing) {
if heal_ok {
tracker.record_heal_success();
} else {
tracker.record_heal_failure();
}
}
}
for (state_entry, detected) in &pending_detections {
let tracker = self.heal_manager.tracker(&state_entry.id);
let (outcome_str, circuit_str) = heal_outcome_strings(tracker, heal_ok);
let healed =
TamperEvent::new_healed(detected, outcome_str, tracker.attempt, circuit_str);
self.publish_healed(&state_entry.hook_event, &healed);
}
}
}
fn publish_detected(&self, event: &TamperEvent) {
if let Some(logger) = &self.sinks.logger {
logger.log(event.clone());
}
if let Some(tx) = &self.sinks.cloud_tx {
let ce = event.to_cloud_event(
&self.sinks.agent_id,
&self.sinks.client_version,
current_os(),
TamperCloudEventKind::Detected,
);
let _ = tx.try_send(ce);
}
telemetry::capture_global(Event::tamper_detected(
&event.tamper.detection_method,
&event.tamper.agent_type,
));
}
fn publish_healed(&self, _hook_event: &str, event: &TamperEvent) {
if let Some(logger) = &self.sinks.logger {
logger.log(event.clone());
}
if let Some(tx) = &self.sinks.cloud_tx {
let ce = event.to_cloud_event(
&self.sinks.agent_id,
&self.sinks.client_version,
current_os(),
TamperCloudEventKind::Healed,
);
let _ = tx.try_send(ce);
}
telemetry::capture_global(Event::tamper_healed(
&event.tamper.detection_method,
&event.tamper.agent_type,
&event.tamper.heal.outcome,
event.tamper.heal.attempt,
));
}
fn try_heal(&self) -> Result<(), ()> {
let token = match std::fs::read_to_string(&self.token_file_path) {
Ok(t) => t.trim().to_string(),
Err(e) => {
tracing::warn!(error = %e, "reconciler: cannot read daemon token for heal");
return Err(());
}
};
match crate::hooks::detect_agent() {
Ok(agent) => match crate::hooks::install_hooks(&agent, self.port, &token) {
Ok(result) => {
tracing::warn!(
hooks_reinstalled = result.entries.len(),
"reconciler: hooks healed via reinstall"
);
Ok(())
}
Err(e) => {
tracing::warn!(error = %e, "reconciler: heal failed");
Err(())
}
},
Err(e) => {
tracing::debug!(error = %e, "reconciler: cannot detect agent for heal");
Err(())
}
}
}
pub fn open_circuits(&self) -> Vec<&str> {
self.heal_manager.open_circuits()
}
}
fn heal_outcome_strings(
tracker: &heal::EntryHealTracker,
heal_ok: bool,
) -> (&'static str, &'static str) {
use heal::HealState;
match tracker.state {
HealState::CircuitOpen { .. } => ("circuit_open", "open"),
HealState::Healthy => (if heal_ok { "succeeded" } else { "failed" }, "closed"),
HealState::Backoff { .. } | HealState::Drifted | HealState::Healing => {
if heal_ok {
("succeeded", "closed")
} else {
("failed", "closed")
}
}
}
}
fn check_entry_drift(
hook_event: &str,
entry_id: &str,
hooks_obj: &serde_json::Map<String, serde_json::Value>,
hmac_key: &[u8],
port: u16,
) -> DriftOutcome {
let event_arr = match hooks_obj.get(hook_event).and_then(|a| a.as_array()) {
Some(a) => a,
None => {
tracing::warn!(
hook_event = %hook_event,
detection_method = "entry_deleted",
"reconciler: hook event array missing"
);
return DriftOutcome::Drifted {
method: "entry_deleted",
deltas: Vec::new(),
};
}
};
let openlatch_entry = event_arr.iter().find(|entry| {
matches!(
classify_marker(entry),
MarkerShape::Legacy | MarkerShape::Current(_)
)
});
match openlatch_entry {
None => {
tracing::warn!(
hook_event = %hook_event,
detection_method = "marker_missing",
"reconciler: openlatch entry missing from hook array"
);
DriftOutcome::Drifted {
method: "marker_missing",
deltas: Vec::new(),
}
}
Some(entry) => match classify_marker(entry) {
MarkerShape::Legacy => {
tracing::info!(
code = crate::error::ERR_LEGACY_MARKER_DETECTED,
hook_event = %hook_event,
detection_method = "legacy_marker_upgrade",
"reconciler: legacy boolean marker found — upgrading"
);
DriftOutcome::Drifted {
method: "legacy_marker_upgrade",
deltas: Vec::new(),
}
}
MarkerShape::Current(ref marker) => {
let hmac = match &marker.hmac {
Some(h) => h,
None => {
return DriftOutcome::Drifted {
method: "marker_missing",
deltas: Vec::new(),
};
}
};
match verify_entry_hmac(entry, hmac, hmac_key) {
Ok(true) => DriftOutcome::Healthy,
Ok(false) => {
tracing::warn!(
hook_event = %hook_event,
entry_id = %entry_id,
detection_method = "hmac_mismatch",
"reconciler: HMAC verification failed — entry tampered"
);
let deltas = compute_field_deltas(hook_event, port, marker, entry);
DriftOutcome::Drifted {
method: "hmac_mismatch",
deltas,
}
}
Err(_) => DriftOutcome::Drifted {
method: "hmac_mismatch",
deltas: Vec::new(),
},
}
}
MarkerShape::Missing => DriftOutcome::Drifted {
method: "marker_missing",
deltas: Vec::new(),
},
},
}
}
fn compute_field_deltas(
hook_event: &str,
port: u16,
marker: &crate::core::hook_state::marker::OpenlatchMarker,
observed: &serde_json::Value,
) -> Vec<FieldDelta> {
const TOKEN_ENV_VAR: &str = "OPENLATCH_TOKEN";
let hook_bin = crate::hooks::resolve_hook_binary_path();
let mut expected = crate::hooks::claude_code::build_hook_entry(
hook_event,
port,
TOKEN_ENV_VAR,
&hook_bin,
marker,
);
if let Ok(marker_value) = serde_json::to_value(marker) {
expected["_openlatch"] = marker_value;
}
diff_entry_fields(&expected, observed)
}
pub fn run_startup_reconcile(settings_path: &Path, openlatch_dir: &Path, port: u16) {
let token_file = openlatch_dir.join("daemon.token");
let mut r = Reconciler {
rx: mpsc::channel(1).1,
settings_path: settings_path.to_path_buf(),
openlatch_dir: openlatch_dir.to_path_buf(),
port,
token_file_path: token_file,
heal_manager: HealManager::new(),
sinks: TamperSinks::default(),
};
r.do_reconcile();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::hook_state::hmac::compute_entry_hmac;
use crate::core::hook_state::marker::OpenlatchMarker;
use heal::{EntryHealTracker, HealState};
#[test]
fn heal_outcome_strings_when_healthy_and_ok() {
let t = EntryHealTracker::new();
assert_eq!(heal_outcome_strings(&t, true), ("succeeded", "closed"));
}
#[test]
fn heal_outcome_strings_when_failed() {
let mut t = EntryHealTracker::new();
t.mark_drifted();
t.record_heal_attempt();
t.record_heal_failure();
let (outcome, circuit) = heal_outcome_strings(&t, false);
assert_eq!(outcome, "failed");
assert_eq!(circuit, "closed");
}
#[test]
fn heal_outcome_strings_when_circuit_open() {
let mut t = EntryHealTracker::new();
for _ in 0..5 {
t.mark_drifted();
t.record_heal_attempt();
t.record_heal_success();
t.mark_drifted();
}
assert!(matches!(t.state, HealState::CircuitOpen { .. }));
assert_eq!(heal_outcome_strings(&t, false), ("circuit_open", "open"));
}
#[test]
fn current_os_returns_a_known_string() {
let os = current_os();
assert!(matches!(os, "linux" | "macos" | "windows"));
}
fn make_signed_entry(hook_event: &str, entry_id: &str, key: &[u8]) -> serde_json::Value {
let marker_no_hmac = OpenlatchMarker::new(entry_id.to_string());
let mut entry = crate::hooks::claude_code::build_hook_entry(
hook_event,
7443,
"OPENLATCH_TOKEN",
std::path::Path::new("/opt/openlatch/bin/openlatch-hook"),
&marker_no_hmac,
);
entry["_openlatch"] = serde_json::to_value(&marker_no_hmac).unwrap();
let hmac = compute_entry_hmac(&entry, key).unwrap();
let marker = marker_no_hmac.with_hmac(hmac);
entry["_openlatch"] = serde_json::to_value(&marker).unwrap();
entry
}
fn build_hooks_obj(hook_event: &str, entry: serde_json::Value) -> serde_json::Value {
serde_json::json!({ hook_event: [entry] })
}
#[test]
fn check_entry_drift_healthy_when_hmac_matches() {
let key = vec![0x42; 32];
let entry = make_signed_entry("PreToolUse", "entry-a", &key);
let hooks = build_hooks_obj("PreToolUse", entry);
let outcome = check_entry_drift(
"PreToolUse",
"entry-a",
hooks.as_object().unwrap(),
&key,
7443,
);
assert!(matches!(outcome, DriftOutcome::Healthy));
}
#[test]
fn check_entry_drift_hmac_mismatch_populates_deltas() {
let key = vec![0x42; 32];
let mut entry = make_signed_entry("PreToolUse", "entry-a", &key);
entry["timeout"] = serde_json::json!(999);
let hooks = build_hooks_obj("PreToolUse", entry);
let outcome = check_entry_drift(
"PreToolUse",
"entry-a",
hooks.as_object().unwrap(),
&key,
7443,
);
match outcome {
DriftOutcome::Drifted { method, deltas } => {
assert_eq!(method, "hmac_mismatch");
assert!(
deltas.iter().any(|d| d.field == "timeout"),
"expected timeout delta, got {deltas:?}"
);
}
_ => panic!("expected Drifted, got {outcome:?}"),
}
}
#[test]
fn check_entry_drift_reports_entry_deleted_when_event_array_missing() {
let key = vec![0x42; 32];
let hooks = serde_json::json!({});
let outcome = check_entry_drift(
"PreToolUse",
"entry-a",
hooks.as_object().unwrap(),
&key,
7443,
);
match outcome {
DriftOutcome::Drifted { method, deltas } => {
assert_eq!(method, "entry_deleted");
assert!(deltas.is_empty());
}
_ => panic!("expected Drifted"),
}
}
#[test]
fn check_entry_drift_reports_marker_missing_when_entry_absent() {
let key = vec![0x42; 32];
let hooks = serde_json::json!({
"PreToolUse": [{"matcher": "", "hooks": [{"type": "command", "command": "other"}]}]
});
let outcome = check_entry_drift(
"PreToolUse",
"entry-a",
hooks.as_object().unwrap(),
&key,
7443,
);
match outcome {
DriftOutcome::Drifted { method, .. } => assert_eq!(method, "marker_missing"),
_ => panic!("expected Drifted"),
}
}
#[test]
fn check_entry_drift_reports_legacy_marker_upgrade() {
let key = vec![0x42; 32];
let hooks = serde_json::json!({
"PreToolUse": [{"_openlatch": true, "matcher": "", "hooks": []}]
});
let outcome = check_entry_drift(
"PreToolUse",
"entry-a",
hooks.as_object().unwrap(),
&key,
7443,
);
match outcome {
DriftOutcome::Drifted { method, .. } => assert_eq!(method, "legacy_marker_upgrade"),
_ => panic!("expected Drifted"),
}
}
#[tokio::test]
async fn publish_detected_fans_out_to_cloud_tx() {
let (_in_tx, in_rx) = mpsc::channel(1);
let (cloud_tx, mut cloud_rx) = mpsc::channel(16);
let tmp = tempfile::tempdir().unwrap();
let (logger, _handle) =
crate::core::logging::tamper_log::TamperLogger::new(tmp.path().to_path_buf());
let reconciler = Reconciler::new_with_sinks(
in_rx,
tmp.path().join("settings.json"),
tmp.path().to_path_buf(),
7443,
tmp.path().join("daemon.token"),
TamperSinks {
logger: Some(logger),
cloud_tx: Some(cloud_tx),
agent_id: "agt_test".into(),
client_version: "0.0.1-test".into(),
},
);
let event = TamperEvent::new(
"entry-a".into(),
"claude-code".into(),
"sha256:abc".into(),
"PreToolUse".into(),
"hmac_mismatch".into(),
);
reconciler.publish_detected(&event);
let ce = cloud_rx.try_recv().expect("cloud channel received event");
assert_eq!(
ce.envelope["type"].as_str(),
Some("ai.openlatch.security.tamper_detected")
);
assert_eq!(ce.agent_id, "agt_test");
}
#[tokio::test]
async fn publish_healed_links_to_detection_event_id() {
let (_in_tx, in_rx) = mpsc::channel(1);
let (cloud_tx, mut cloud_rx) = mpsc::channel(16);
let tmp = tempfile::tempdir().unwrap();
let (logger, _handle) =
crate::core::logging::tamper_log::TamperLogger::new(tmp.path().to_path_buf());
let reconciler = Reconciler::new_with_sinks(
in_rx,
tmp.path().join("settings.json"),
tmp.path().to_path_buf(),
7443,
tmp.path().join("daemon.token"),
TamperSinks {
logger: Some(logger),
cloud_tx: Some(cloud_tx),
agent_id: "agt_test".into(),
client_version: "0.0.1-test".into(),
},
);
let detected = TamperEvent::new(
"entry-a".into(),
"claude-code".into(),
"sha256:abc".into(),
"PreToolUse".into(),
"hmac_mismatch".into(),
);
let healed = TamperEvent::new_healed(&detected, "succeeded", 1, "closed");
reconciler.publish_healed("PreToolUse", &healed);
let ce = cloud_rx.try_recv().expect("cloud channel received event");
assert_eq!(
ce.envelope["type"].as_str(),
Some("ai.openlatch.security.tamper_healed")
);
assert_eq!(
ce.envelope["data"]["tamper"]["related_event_id"].as_str(),
Some(detected.tamper.event_id.as_str())
);
}
}