pub mod heal;
pub mod request;
use std::path::{Path, PathBuf};
use std::sync::Arc;
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::binding::AgentBinding;
use crate::hooks::jsonc;
use heal::HealManager;
use request::ReconcileRequest;
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>,
},
}
#[derive(Clone)]
pub struct AgentTarget {
pub settings_path: PathBuf,
pub binding: Arc<dyn AgentBinding>,
}
impl AgentTarget {
pub fn for_agent(agent: &crate::hooks::DetectedAgent) -> Self {
Self {
settings_path: agent.settings_path(),
binding: agent.binding.clone(),
}
}
}
pub struct Reconciler {
rx: mpsc::Receiver<ReconcileRequest>,
targets: Vec<AgentTarget>,
openlatch_dir: PathBuf,
port: u16,
token_file_path: PathBuf,
heal_manager: HealManager,
sinks: TamperSinks,
}
impl Reconciler {
pub fn new(
rx: mpsc::Receiver<ReconcileRequest>,
targets: Vec<AgentTarget>,
openlatch_dir: PathBuf,
port: u16,
token_file_path: PathBuf,
) -> Self {
Self::new_with_sinks(
rx,
targets,
openlatch_dir,
port,
token_file_path,
TamperSinks::default(),
)
}
pub fn new_with_sinks(
rx: mpsc::Receiver<ReconcileRequest>,
targets: Vec<AgentTarget>,
openlatch_dir: PathBuf,
port: u16,
token_file_path: PathBuf,
sinks: TamperSinks,
) -> Self {
Self {
rx,
targets,
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 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;
}
};
for target in self.targets.clone() {
self.reconcile_target(&target, &state, &hmac_key);
}
}
fn reconcile_target(&mut self, target: &AgentTarget, state: &HookStateFile, hmac_key: &[u8]) {
let raw = match std::fs::read_to_string(&target.settings_path) {
Ok(c) => c,
Err(e) => {
tracing::debug!(error = %e, "reconciler: cannot read settings.json");
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 tracked_in(state, &hash_settings_path(&target.settings_path))
.next()
.is_some()
{
tracing::warn!(
"reconciler: hooks object missing from settings.json — will heal"
);
let _ = self.try_heal(target);
}
return;
}
};
let settings_path_hash = hash_settings_path(&target.settings_path);
let mut pending_detections: Vec<(StateEntry, TamperEvent)> = Vec::new();
let mut drift_results: Vec<(String, bool)> = Vec::new();
for state_entry in tracked_in(state, &settings_path_hash) {
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,
&*target.binding,
);
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(),
target.binding.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(target).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, target: &AgentTarget) -> 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::install_hooks(&*target.binding, self.port, &token) {
Ok(result) => {
tracing::warn!(
hooks_reinstalled = result.entries.len(),
agent = target.binding.agent_type(),
"reconciler: hooks healed via reinstall"
);
Ok(())
}
Err(e) => {
tracing::warn!(error = %e, "reconciler: heal failed");
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 tracked_in<'a>(
state: &'a HookStateFile,
settings_path_hash: &'a str,
) -> impl Iterator<Item = &'a StateEntry> {
state
.entries
.iter()
.filter(move |e| e.settings_path_hash == settings_path_hash)
}
fn check_entry_drift(
hook_event: &str,
entry_id: &str,
hooks_obj: &serde_json::Map<String, serde_json::Value>,
hmac_key: &[u8],
port: u16,
binding: &dyn AgentBinding,
) -> 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, binding);
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,
binding: &dyn AgentBinding,
) -> Vec<FieldDelta> {
let hook_bin = crate::hooks::resolve_hook_binary_path();
let mut expected = binding.build_hook_entry(hook_event, &hook_bin, port, 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(targets: Vec<AgentTarget>, openlatch_dir: &Path, port: u16) {
let token_file = openlatch_dir.join("daemon.token");
let mut r = Reconciler {
rx: mpsc::channel(1).1,
targets,
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 crate::hooks::bindings::claude_code::ClaudeCodeBinding;
use heal::{EntryHealTracker, HealState};
fn fixture_binding() -> ClaudeCodeBinding {
ClaudeCodeBinding {
claude_dir: std::path::PathBuf::from("/opt/openlatch/.claude"),
settings_path: std::path::PathBuf::from("/opt/openlatch/.claude/settings.json"),
}
}
#[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 = fixture_binding().build_hook_entry(
hook_event,
std::path::Path::new("/opt/openlatch/bin/openlatch-hook"),
7443,
&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,
&fixture_binding(),
);
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,
&fixture_binding(),
);
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,
&fixture_binding(),
);
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,
&fixture_binding(),
);
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,
&fixture_binding(),
);
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,
vec![AgentTarget {
settings_path: tmp.path().join("settings.json"),
binding: Arc::new(fixture_binding()),
}],
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,
vec![AgentTarget {
settings_path: tmp.path().join("settings.json"),
binding: Arc::new(fixture_binding()),
}],
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())
);
}
struct TwoAgents {
_root: tempfile::TempDir,
openlatch_dir: std::path::PathBuf,
agents: Vec<crate::hooks::DetectedAgent>,
key: Vec<u8>,
}
impl TwoAgents {
fn new() -> Self {
let root = tempfile::tempdir().expect("fixture root");
let openlatch_dir = root.path().join("openlatch");
std::fs::create_dir_all(&openlatch_dir).expect("openlatch dir");
let key = HmacKeyStore::new(&openlatch_dir)
.load_or_create()
.expect("hmac key");
let agents = crate::hooks::binding::test_support::two_detected_agents(root.path());
Self {
_root: root,
openlatch_dir,
agents,
key,
}
}
fn target(&self, i: usize) -> AgentTarget {
AgentTarget::for_agent(&self.agents[i])
}
fn seed_config(&self, i: usize, hooks: serde_json::Value) {
std::fs::write(
self.agents[i].settings_path(),
serde_json::json!({ "hooks": hooks }).to_string(),
)
.expect("seed agent config");
}
fn tracked_entry(&self, i: usize, hook_event: &str, id: &str) -> StateEntry {
StateEntry {
id: id.into(),
agent: self.agents[i].binding.agent_type().into(),
settings_path_hash: hash_settings_path(&self.agents[i].settings_path()),
hook_event: hook_event.into(),
expected_entry_hmac: String::new(),
daemon_port_at_install: 7443,
daemon_token_fp: "fp".into(),
v: 1,
}
}
fn save_state(&self, entries: Vec<StateEntry>) {
let mut state = HookStateFile::new("kid-01".into());
for e in entries {
state.upsert_entry(e);
}
state.save(&self.openlatch_dir).expect("save state");
}
fn reconciler(
&self,
targets: Vec<AgentTarget>,
) -> (Reconciler, mpsc::Receiver<CloudEvent>) {
let (cloud_tx, cloud_rx) = mpsc::channel(32);
let r = Reconciler::new_with_sinks(
mpsc::channel(1).1,
targets,
self.openlatch_dir.clone(),
7443,
self.openlatch_dir.join("daemon.token"),
TamperSinks {
logger: None,
cloud_tx: Some(cloud_tx),
agent_id: "agt_test".into(),
client_version: "0.0.1-test".into(),
},
);
(r, cloud_rx)
}
}
fn detections(rx: &mut mpsc::Receiver<CloudEvent>) -> Vec<(String, String)> {
let mut found = Vec::new();
while let Ok(ce) = rx.try_recv() {
if ce.envelope["type"].as_str() == Some("ai.openlatch.security.tamper_detected") {
found.push((
ce.envelope["source"]
.as_str()
.unwrap_or_default()
.to_string(),
ce.envelope["data"]["tamper"]["hook_event"]
.as_str()
.unwrap_or_default()
.to_string(),
));
}
}
found
}
#[test]
fn reconcile_ignores_an_entry_from_another_agents_file() {
let fx = TwoAgents::new();
let entry = make_signed_entry("PreToolUse", "entry-a", &fx.key);
fx.seed_config(0, build_hooks_obj("PreToolUse", entry));
fx.seed_config(1, serde_json::json!({}));
fx.save_state(vec![
fx.tracked_entry(0, "PreToolUse", "entry-a"),
fx.tracked_entry(1, "SessionEnd", "entry-b"),
]);
let (mut r, mut rx) = fx.reconciler(vec![fx.target(0)]);
r.do_reconcile();
assert_eq!(
detections(&mut rx),
Vec::<(String, String)>::new(),
"the second agent's entry is not this file's to verify"
);
}
#[test]
fn reconcile_watches_every_detected_agent() {
let fx = TwoAgents::new();
fx.seed_config(
0,
build_hooks_obj(
"PreToolUse",
make_signed_entry("PreToolUse", "entry-a", &fx.key),
),
);
let mut tampered = make_signed_entry("SessionEnd", "entry-b", &fx.key);
tampered["timeout"] = serde_json::json!(999);
fx.seed_config(1, build_hooks_obj("SessionEnd", tampered));
fx.save_state(vec![
fx.tracked_entry(0, "PreToolUse", "entry-a"),
fx.tracked_entry(1, "SessionEnd", "entry-b"),
]);
let (mut r, mut rx) = fx.reconciler(vec![fx.target(0), fx.target(1)]);
r.do_reconcile();
assert_eq!(
detections(&mut rx),
vec![("cursor".to_string(), "SessionEnd".to_string())],
"the second agent's tampered entry must be detected, and attributed to it"
);
}
}