1use std::collections::HashMap;
54use std::sync::{Arc, Mutex};
55use std::time::{SystemTime, UNIX_EPOCH};
56
57use meerkat_core::event::AgentEvent;
58use serde::{Deserialize, Serialize};
59
60use crate::identity_first::agent_memory::AgentMemoryLlmWrites;
61use crate::memory::records::{EvidenceRef, MemoryAuthor};
62use crate::memory::staged::StagedBatchKind;
63
64const ALWAYS_UNTRUSTED_TOOL_NAMES: &[&str] = &["web_search", "web_fetch", "fetch", "http_request"];
70
71const MCP_QUALIFIED_PREFIX: &str = "mcp__";
75
76const MAX_TRACKED_TAINTED_SESSIONS: usize = 4096;
80
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89pub struct ContentTrustConfig {
90 #[serde(default)]
93 pub trusted_mcp_servers: Vec<String>,
94 #[serde(default)]
97 pub untrusted_tools: Vec<String>,
98 #[serde(default)]
101 pub trusted_tools: Vec<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum ToolContentTrust {
107 Trusted,
108 Untrusted { source: String },
109}
110
111impl ContentTrustConfig {
112 pub fn from_json_value(value: &serde_json::Value) -> Result<Self, String> {
116 let object = value
117 .as_object()
118 .ok_or_else(|| "content_trust must be an object".to_string())?;
119 let supported = ["trusted_mcp_servers", "untrusted_tools", "trusted_tools"];
120 let unsupported = object
121 .keys()
122 .filter(|key| !supported.contains(&key.as_str()))
123 .map(String::as_str)
124 .collect::<Vec<_>>();
125 if !unsupported.is_empty() {
126 return Err(format!(
127 "unsupported content_trust fields: {}",
128 unsupported.join(", ")
129 ));
130 }
131 let parse_names = |key: &str| -> Result<Vec<String>, String> {
132 match object.get(key) {
133 None => Ok(Vec::new()),
134 Some(value) => {
135 let entries = value
136 .as_array()
137 .ok_or_else(|| format!("content_trust.{key} must be an array"))?;
138 entries
139 .iter()
140 .map(|entry| {
141 entry
142 .as_str()
143 .map(str::trim)
144 .filter(|name| !name.is_empty())
145 .map(ToString::to_string)
146 .ok_or_else(|| {
147 format!("content_trust.{key} entries must be non-empty strings")
148 })
149 })
150 .collect()
151 }
152 }
153 };
154 Ok(Self {
155 trusted_mcp_servers: parse_names("trusted_mcp_servers")?,
156 untrusted_tools: parse_names("untrusted_tools")?,
157 trusted_tools: parse_names("trusted_tools")?,
158 })
159 }
160
161 pub fn classify_tool(&self, name: &str) -> ToolContentTrust {
166 if ALWAYS_UNTRUSTED_TOOL_NAMES.contains(&name) {
167 return ToolContentTrust::Untrusted {
168 source: format!("web tool '{name}'"),
169 };
170 }
171 if self.untrusted_tools.iter().any(|tool| tool == name) {
172 return ToolContentTrust::Untrusted {
173 source: format!("configured untrusted tool '{name}'"),
174 };
175 }
176 if self.trusted_tools.iter().any(|tool| tool == name) {
177 return ToolContentTrust::Trusted;
178 }
179 if let Some(rest) = name.strip_prefix(MCP_QUALIFIED_PREFIX) {
180 let server = rest.split("__").next().unwrap_or(rest);
181 if self
182 .trusted_mcp_servers
183 .iter()
184 .any(|trusted| trusted == server)
185 {
186 return ToolContentTrust::Trusted;
187 }
188 return ToolContentTrust::Untrusted {
189 source: format!("MCP server '{server}' (tool '{name}')"),
190 };
191 }
192 ToolContentTrust::Trusted
193 }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct TaintState {
199 pub tainted_at_ms: u64,
200 pub source: String,
201}
202
203#[derive(Default)]
204struct TaintInner {
205 current_session: HashMap<String, String>,
210 tainted: HashMap<String, TaintState>,
215 pending_identity_taint: HashMap<String, TaintState>,
219 reset_boundaries: HashMap<String, u64>,
222}
223
224pub type OutboundTaintDeclarer =
232 Arc<dyn Fn(&str, Option<meerkat_core::comms::SenderContentTaint>) + Send + Sync>;
233
234#[derive(Clone, Default)]
237pub struct SessionTaintTracker {
238 config: Arc<ContentTrustConfig>,
239 inner: Arc<Mutex<TaintInner>>,
240 event_sink: Arc<Mutex<Option<Arc<dyn crate::memory::events::MemoryEventSink>>>>,
242 outbound_declarer: Arc<Mutex<Option<OutboundTaintDeclarer>>>,
246}
247
248impl SessionTaintTracker {
249 pub fn new(config: ContentTrustConfig) -> Self {
250 Self {
251 config: Arc::new(config),
252 inner: Arc::new(Mutex::new(TaintInner::default())),
253 event_sink: Arc::new(Mutex::new(None)),
254 outbound_declarer: Arc::new(Mutex::new(None)),
255 }
256 }
257
258 pub fn set_outbound_taint_declarer(&self, declarer: OutboundTaintDeclarer) {
261 *self
262 .outbound_declarer
263 .lock()
264 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(declarer);
265 }
266
267 fn declare_outbound(
271 &self,
272 identity: &str,
273 taint: Option<meerkat_core::comms::SenderContentTaint>,
274 ) {
275 if let Some(declarer) = self
276 .outbound_declarer
277 .lock()
278 .unwrap_or_else(std::sync::PoisonError::into_inner)
279 .as_ref()
280 {
281 declarer(identity, taint);
282 }
283 }
284
285 pub fn set_event_sink(&self, sink: Arc<dyn crate::memory::events::MemoryEventSink>) {
288 *self
289 .event_sink
290 .lock()
291 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sink);
292 }
293
294 fn emit_event(&self, event: crate::memory::events::MemoryTimelineEvent) {
295 if let Some(sink) = self
296 .event_sink
297 .lock()
298 .unwrap_or_else(std::sync::PoisonError::into_inner)
299 .as_ref()
300 {
301 sink.emit(event);
302 }
303 }
304
305 pub fn observe_agent_event(&self, identity: &str, event: &AgentEvent) {
308 match event {
309 AgentEvent::RunStarted { session_id, input } => {
310 let session_key = session_id.to_string();
311 self.note_current_session(identity, &session_key);
312 if let Some(text) = input.prompt_text() {
317 self.observe_inbound_peer_content(identity, &session_key, &text);
318 }
319 }
320 AgentEvent::PeerContentIngested {
327 peer, sender_taint, ..
328 } => {
329 if *sender_taint == Some(meerkat_core::comms::SenderContentTaint::Tainted) {
330 let sender = peer
331 .as_ref()
332 .and_then(|peer| peer.display_name.clone())
333 .unwrap_or_else(|| "peer".to_string());
334 self.mark_identity_tainted(
335 identity,
336 format!("peer content declared tainted by sender '{sender}'"),
337 );
338 }
339 }
340 AgentEvent::ToolResultReceived { name, .. }
344 | AgentEvent::ToolExecutionCompleted { name, .. } => {
345 if let ToolContentTrust::Untrusted { source } = self.config.classify_tool(name) {
346 self.mark_identity_tainted(identity, source);
347 }
348 }
349 AgentEvent::ServerToolContent { kind, .. } => {
352 self.mark_identity_tainted(
353 identity,
354 format!("provider server tool '{}'", kind.provider_name()),
355 );
356 }
357 _ => {}
358 }
359 }
360
361 pub fn note_current_session(&self, identity: &str, session_key: &str) {
365 let mut inner = self
366 .inner
367 .lock()
368 .unwrap_or_else(std::sync::PoisonError::into_inner);
369 let pending = inner.pending_identity_taint.remove(identity);
370 let previous = inner
371 .current_session
372 .insert(identity.to_string(), session_key.to_string());
373 let rotated_away_from_tainted = previous.as_deref() != Some(session_key)
374 && previous.is_some_and(|prior| inner.tainted.contains_key(&prior));
375 if rotated_away_from_tainted {
376 tracing::warn!(
377 identity,
378 session_key,
379 "agent memory taint: session rotated away from a tainted session; \
380 new session starts clean"
381 );
382 self.emit_event(
383 crate::memory::events::MemoryTimelineEvent::TaintTransition {
384 identity: Some(identity.to_string()),
385 session_key: session_key.to_string(),
386 kind: "rotated_clean".to_string(),
387 source: "session rotation away from tainted session".to_string(),
388 },
389 );
390 }
391 let pending_reapplied = pending.is_some();
394 if let Some(state) = pending {
395 self.insert_taint(&mut inner, session_key.to_string(), state);
396 }
397 if rotated_away_from_tainted && !pending_reapplied {
400 drop(inner);
401 self.declare_outbound(identity, None);
402 }
403 }
404
405 pub fn clear_identity(&self, identity: &str) {
413 let mut inner = self
414 .inner
415 .lock()
416 .unwrap_or_else(std::sync::PoisonError::into_inner);
417 inner.pending_identity_taint.remove(identity);
418 inner.current_session.remove(identity);
419 drop(inner);
422 self.declare_outbound(identity, None);
423 }
424
425 pub fn mark_reset_boundary(&self, session_key: &str) {
429 let mut inner = self
430 .inner
431 .lock()
432 .unwrap_or_else(std::sync::PoisonError::into_inner);
433 if inner
434 .reset_boundaries
435 .insert(session_key.to_string(), now_ms())
436 .is_none()
437 {
438 tracing::warn!(
439 session_key,
440 "agent memory taint: reset boundary marked; distillates over this \
441 session will land quarantined pending steward review (§8.4)"
442 );
443 self.emit_event(
444 crate::memory::events::MemoryTimelineEvent::TaintTransition {
445 identity: None,
446 session_key: session_key.to_string(),
447 kind: "reset_boundary".to_string(),
448 source: "reset() boundary (§8.4)".to_string(),
449 },
450 );
451 }
452 if inner.reset_boundaries.len() > MAX_TRACKED_TAINTED_SESSIONS
453 && let Some(oldest) = inner
454 .reset_boundaries
455 .iter()
456 .min_by_key(|(_, at_ms)| **at_ms)
457 .map(|(key, _)| key.clone())
458 {
459 inner.reset_boundaries.remove(&oldest);
460 }
461 }
462
463 pub fn evidence_quarantine_reason(&self, session_key: &str) -> Option<String> {
468 let inner = self
469 .inner
470 .lock()
471 .unwrap_or_else(std::sync::PoisonError::into_inner);
472 if let Some(state) = inner.tainted.get(session_key) {
473 return Some(format!(
474 "evidence session tainted by {} (session-tainted ⇒ range-tainted)",
475 state.source
476 ));
477 }
478 if inner.reset_boundaries.contains_key(session_key) {
479 return Some(
480 "evidence session closed at a reset boundary; distillates quarantine \
481 pending steward review (§8.4)"
482 .to_string(),
483 );
484 }
485 None
486 }
487
488 pub fn session_taint(&self, session_key: &str) -> Option<TaintState> {
490 let inner = self
491 .inner
492 .lock()
493 .unwrap_or_else(std::sync::PoisonError::into_inner);
494 inner.tainted.get(session_key).cloned()
495 }
496
497 pub fn identity_taint(&self, identity: &str) -> Option<TaintState> {
500 let inner = self
501 .inner
502 .lock()
503 .unwrap_or_else(std::sync::PoisonError::into_inner);
504 if let Some(state) = inner.pending_identity_taint.get(identity) {
505 return Some(state.clone());
506 }
507 inner
508 .current_session
509 .get(identity)
510 .and_then(|session| inner.tainted.get(session))
511 .cloned()
512 }
513
514 fn observe_inbound_peer_content(&self, identity: &str, session_key: &str, text: &str) {
539 for line in text.lines() {
540 let Some(sender_identity) = peer_projection_sender_identity(line) else {
541 continue;
542 };
543 if sender_identity == identity {
544 continue;
545 }
546 let source = {
547 let inner = self
548 .inner
549 .lock()
550 .unwrap_or_else(std::sync::PoisonError::into_inner);
551 inner
552 .pending_identity_taint
553 .get(sender_identity)
554 .or_else(|| {
555 inner
556 .current_session
557 .get(sender_identity)
558 .and_then(|session| inner.tainted.get(session))
559 })
560 .map(|state| state.source.clone())
561 };
562 if let Some(source) = source {
563 let state = TaintState {
564 tainted_at_ms: now_ms(),
565 source: format!(
566 "peer message from tainted sender '{sender_identity}' \
567 (sender session tainted by {source})"
568 ),
569 };
570 let mut inner = self
571 .inner
572 .lock()
573 .unwrap_or_else(std::sync::PoisonError::into_inner);
574 self.insert_taint(&mut inner, session_key.to_string(), state);
575 }
576 }
577 }
578
579 fn mark_identity_tainted(&self, identity: &str, source: String) {
580 let state = TaintState {
581 tainted_at_ms: now_ms(),
582 source,
583 };
584 let mut inner = self
585 .inner
586 .lock()
587 .unwrap_or_else(std::sync::PoisonError::into_inner);
588 match inner.current_session.get(identity).cloned() {
589 Some(session) => self.insert_taint(&mut inner, session, state),
590 None => {
591 match inner.pending_identity_taint.entry(identity.to_string()) {
594 std::collections::hash_map::Entry::Vacant(slot) => {
595 tracing::warn!(
596 identity,
597 source = %state.source,
598 "agent memory taint: untrusted ingestion observed before session \
599 attribution; holding identity-sticky taint"
600 );
601 slot.insert(state);
602 }
603 std::collections::hash_map::Entry::Occupied(mut slot) => {
604 slot.insert(state);
605 }
606 }
607 }
608 }
609 drop(inner);
613 self.declare_outbound(
614 identity,
615 Some(meerkat_core::comms::SenderContentTaint::Tainted),
616 );
617 }
618
619 fn insert_taint(&self, inner: &mut TaintInner, session: String, state: TaintState) {
620 match inner.tainted.entry(session) {
621 std::collections::hash_map::Entry::Occupied(_) => return,
622 std::collections::hash_map::Entry::Vacant(slot) => {
623 tracing::warn!(
624 session_key = %slot.key(),
625 source = %state.source,
626 "agent memory taint: session ingested untrusted content; LLM-authored \
627 memory writes from this session will quarantine until a fresh-context \
628 boundary (reset/respawn/fresh spawn)"
629 );
630 self.emit_event(
631 crate::memory::events::MemoryTimelineEvent::TaintTransition {
632 identity: None,
633 session_key: slot.key().clone(),
634 kind: "tainted".to_string(),
635 source: state.source.clone(),
636 },
637 );
638 slot.insert(state);
639 }
640 }
641 if inner.tainted.len() > MAX_TRACKED_TAINTED_SESSIONS
642 && let Some(oldest) = inner
643 .tainted
644 .iter()
645 .min_by_key(|(_, state)| state.tainted_at_ms)
646 .map(|(key, _)| key.clone())
647 {
648 inner.tainted.remove(&oldest);
649 }
650 }
651}
652
653pub trait LlmWriteGate: Send + Sync {
658 fn quarantine_reason(
665 &self,
666 author: &MemoryAuthor,
667 kind: StagedBatchKind,
668 evidence: &[EvidenceRef],
669 ) -> Option<String>;
670}
671
672pub struct TaintLlmWriteGate {
693 tracker: Option<SessionTaintTracker>,
694 llm_writes: AgentMemoryLlmWrites,
695}
696
697impl TaintLlmWriteGate {
698 pub fn new(tracker: Option<SessionTaintTracker>, llm_writes: AgentMemoryLlmWrites) -> Self {
699 Self {
700 tracker,
701 llm_writes,
702 }
703 }
704}
705
706impl LlmWriteGate for TaintLlmWriteGate {
707 fn quarantine_reason(
708 &self,
709 author: &MemoryAuthor,
710 kind: StagedBatchKind,
711 evidence: &[EvidenceRef],
712 ) -> Option<String> {
713 if !author.is_llm() {
714 return None;
715 }
716 if self.llm_writes == AgentMemoryLlmWrites::Quarantined
717 && kind != StagedBatchKind::ReviewVerdict
718 {
719 return Some("llm_writes=quarantined policy".to_string());
720 }
721 let tracker = self.tracker.as_ref()?;
722 if let MemoryAuthor::Agent { identity } = author
723 && let Some(state) = tracker.identity_taint(identity)
724 {
725 return Some(format!("session tainted by {}", state.source));
726 }
727 for evidence_ref in evidence {
728 if let Some(reason) = tracker.evidence_quarantine_reason(&evidence_ref.session_id) {
729 return Some(reason);
730 }
731 }
732 None
733 }
734}
735
736fn peer_projection_sender_identity(line: &str) -> Option<&str> {
745 let name = if let Some(rest) = line.strip_prefix("Peer message from ") {
746 match rest.split_once(": ") {
750 Some((name, _)) => name.trim(),
751 None => rest.strip_suffix(':').unwrap_or(rest).trim(),
752 }
753 } else {
754 let rest = line.strip_prefix("Peer response from ")?;
755 rest.split(" (to request:").next()?.trim()
756 };
757 if name.is_empty() {
758 return None;
759 }
760 Some(name.rsplit('/').next().unwrap_or(name))
761}
762
763pub trait MemberAgentEventSink: Send + Sync {
772 fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>);
773}
774
775impl MemberAgentEventSink for SessionTaintTracker {
776 fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
777 self.observe_agent_event(identity, &envelope.payload);
778 }
779}
780
781pub struct CompactionResetSink {
788 on_compacted: Arc<dyn Fn(&str) + Send + Sync>,
789}
790
791impl CompactionResetSink {
792 pub fn new(on_compacted: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
793 Self { on_compacted }
794 }
795}
796
797impl MemberAgentEventSink for CompactionResetSink {
798 fn observe(&self, _identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
799 if matches!(envelope.payload, AgentEvent::CompactionCompleted { .. })
800 && let meerkat_core::event::EventSourceIdentity::Session { session_id } =
801 &envelope.source
802 {
803 (self.on_compacted)(&session_id.to_string());
804 }
805 }
806}
807
808#[derive(Clone)]
811pub struct TaintObserverGuard {
812 _abort: Arc<AbortOnDrop>,
813}
814
815struct AbortOnDrop(tokio::task::JoinHandle<()>);
816
817impl Drop for AbortOnDrop {
818 fn drop(&mut self) {
819 self.0.abort();
820 }
821}
822
823pub fn spawn_taint_observer(
827 handle: meerkat_mob::MobHandle,
828 tracker: SessionTaintTracker,
829) -> TaintObserverGuard {
830 spawn_member_event_observer(handle, vec![Arc::new(tracker)])
831}
832
833pub fn spawn_member_event_observer(
837 handle: meerkat_mob::MobHandle,
838 sinks: Vec<Arc<dyn MemberAgentEventSink>>,
839) -> TaintObserverGuard {
840 let task = tokio::spawn(run_member_event_observer(handle, sinks));
841 TaintObserverGuard {
842 _abort: Arc::new(AbortOnDrop(task)),
843 }
844}
845
846async fn run_member_event_observer(
847 handle: meerkat_mob::MobHandle,
848 sinks: Vec<Arc<dyn MemberAgentEventSink>>,
849) {
850 use futures::StreamExt;
851 use futures::stream::SelectAll;
852
853 enum Observed {
854 Event(String, Box<meerkat_core::event::EventEnvelope<AgentEvent>>),
855 Closed(String),
856 }
857
858 let mut streams: SelectAll<futures::stream::BoxStream<'static, Observed>> = SelectAll::new();
859 let mut subscribed: std::collections::HashSet<String> = std::collections::HashSet::new();
860 let mut warned: std::collections::HashSet<String> = std::collections::HashSet::new();
861 let mut reconcile = tokio::time::interval(std::time::Duration::from_secs(1));
862 reconcile.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
863
864 loop {
865 tokio::select! {
866 Some(observed) = streams.next() => match observed {
867 Observed::Event(identity, envelope) => {
868 for sink in &sinks {
869 sink.observe(&identity, &envelope);
870 }
871 }
872 Observed::Closed(identity) => {
873 subscribed.remove(&identity);
874 }
875 },
876 _ = reconcile.tick() => {
877 for entry in handle.list_members_including_retiring().await {
878 if entry.status != meerkat_mob::MobMemberStatus::Active {
882 continue;
883 }
884 let identity = entry.agent_identity.to_string();
885 if subscribed.contains(&identity) {
886 continue;
887 }
888 match handle.subscribe_agent_events(&entry.agent_identity).await {
889 Ok(stream) => {
890 warned.remove(&identity);
891 subscribed.insert(identity.clone());
892 let close_key = identity.clone();
893 streams.push(
894 stream
895 .map(move |envelope| {
896 Observed::Event(identity.clone(), Box::new(envelope))
897 })
898 .chain(futures::stream::once(async move {
899 Observed::Closed(close_key)
900 }))
901 .boxed(),
902 );
903 }
904 Err(error) => {
905 if warned.insert(identity.clone()) {
908 tracing::warn!(
909 identity = %identity,
910 error = %error,
911 "agent memory taint observer: failed to subscribe; will retry"
912 );
913 } else {
914 tracing::debug!(
915 identity = %identity,
916 error = %error,
917 "agent memory taint observer: subscribe still failing"
918 );
919 }
920 }
921 }
922 }
923 }
924 }
925 }
926}
927
928fn now_ms() -> u64 {
929 SystemTime::now()
930 .duration_since(UNIX_EPOCH)
931 .map(|duration| duration.as_millis() as u64)
932 .unwrap_or(0)
933}
934
935#[cfg(test)]
936#[allow(
937 clippy::expect_used,
938 clippy::panic,
939 clippy::redundant_clone,
940 clippy::unwrap_used
941)]
942mod tests {
943 use super::*;
944 use meerkat_core::types::{ContentBlock, ServerToolKind, SessionId};
945 use serde_json::json;
946
947 fn run_started(session: &SessionId) -> AgentEvent {
948 AgentEvent::RunStarted {
949 session_id: session.clone(),
950 input: meerkat_core::types::RunInput::Content {
951 content: meerkat_core::ContentInput::Text("hi".to_string()),
952 },
953 }
954 }
955
956 fn tool_result(name: &str) -> AgentEvent {
957 AgentEvent::ToolResultReceived {
958 id: "tool-1".to_string(),
959 name: name.to_string(),
960 content: vec![ContentBlock::Text {
961 text: "ok".to_string(),
962 }],
963 is_error: false,
964 }
965 }
966
967 fn peer_ingested(taint: Option<meerkat_core::comms::SenderContentTaint>) -> AgentEvent {
968 AgentEvent::PeerContentIngested {
969 kind: meerkat_core::types::CommsNoticeKind::Message,
970 peer: None,
971 request_id: None,
972 sender_taint: taint,
973 }
974 }
975
976 #[test]
980 fn declared_peer_taint_taints_receiver_but_none_or_clean_does_not() {
981 use meerkat_core::comms::SenderContentTaint;
982 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
983 tracker.note_current_session("identity:b", "sess-b");
984
985 tracker.observe_agent_event("identity:b", &peer_ingested(None));
986 assert!(
987 tracker.session_taint("sess-b").is_none(),
988 "no declaration must not taint"
989 );
990
991 tracker.observe_agent_event(
992 "identity:b",
993 &peer_ingested(Some(SenderContentTaint::Clean)),
994 );
995 assert!(
996 tracker.session_taint("sess-b").is_none(),
997 "an affirmative Clean declaration must not taint"
998 );
999
1000 tracker.observe_agent_event(
1001 "identity:b",
1002 &peer_ingested(Some(SenderContentTaint::Tainted)),
1003 );
1004 assert!(
1005 tracker.session_taint("sess-b").is_some(),
1006 "a declared-tainted peer delivery taints the receiving session"
1007 );
1008 }
1009
1010 #[test]
1011 fn taint_transitions_emit_timeline_events_when_sink_wired() {
1012 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1013 let sink = std::sync::Arc::new(crate::memory::events::CollectingEventSink::new());
1014 tracker.set_event_sink(sink.clone());
1015
1016 tracker.note_current_session("identity:a", "sess-1");
1017 tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1018 tracker.mark_reset_boundary("sess-1");
1019 tracker.mark_reset_boundary("sess-1");
1021 tracker.note_current_session("identity:a", "sess-2");
1022
1023 let types = sink.types();
1024 assert_eq!(
1025 types,
1026 vec![
1027 "memory.taint.transition", "memory.taint.transition", "memory.taint.transition", ]
1031 );
1032 let events = sink.events.lock().unwrap();
1033 let kinds: Vec<String> = events
1034 .iter()
1035 .map(|event| match event {
1036 crate::memory::events::MemoryTimelineEvent::TaintTransition { kind, .. } => {
1037 kind.clone()
1038 }
1039 other => panic!("unexpected event {other:?}"),
1040 })
1041 .collect();
1042 assert_eq!(kinds, vec!["tainted", "reset_boundary", "rotated_clean"]);
1043 }
1044
1045 #[test]
1046 fn content_trust_parse_rejects_unknown_fields_and_bad_types() {
1047 let err = ContentTrustConfig::from_json_value(&json!({"servers": []}))
1048 .expect_err("unknown field must fail loud");
1049 assert!(err.contains("unsupported content_trust fields"), "{err}");
1050 let err = ContentTrustConfig::from_json_value(&json!({"trusted_mcp_servers": "kg"}))
1051 .expect_err("non-array must fail loud");
1052 assert!(err.contains("must be an array"), "{err}");
1053 let err = ContentTrustConfig::from_json_value(&json!({"untrusted_tools": [1]}))
1054 .expect_err("non-string entry must fail loud");
1055 assert!(err.contains("non-empty strings"), "{err}");
1056 let err =
1057 ContentTrustConfig::from_json_value(&json!([])).expect_err("non-object must fail loud");
1058 assert!(err.contains("must be an object"), "{err}");
1059 }
1060
1061 #[test]
1062 fn content_trust_parse_accepts_full_block() {
1063 let config = ContentTrustConfig::from_json_value(&json!({
1064 "trusted_mcp_servers": ["knowledge_graph"],
1065 "untrusted_tools": ["scrape_page"],
1066 "trusted_tools": ["mcp__scanner__lint"],
1067 }))
1068 .expect("valid block parses");
1069 assert_eq!(config.trusted_mcp_servers, vec!["knowledge_graph"]);
1070 assert_eq!(config.untrusted_tools, vec!["scrape_page"]);
1071 assert_eq!(config.trusted_tools, vec!["mcp__scanner__lint"]);
1072 }
1073
1074 #[test]
1075 fn classification_precedence_holds() {
1076 let config = ContentTrustConfig {
1077 trusted_mcp_servers: vec!["kg".to_string()],
1078 untrusted_tools: vec!["scrape_page".to_string()],
1079 trusted_tools: vec!["web_search".to_string(), "mcp__evil__probe".to_string()],
1081 };
1082 assert!(matches!(
1083 config.classify_tool("web_search"),
1084 ToolContentTrust::Untrusted { .. }
1085 ));
1086 assert!(matches!(
1087 config.classify_tool("scrape_page"),
1088 ToolContentTrust::Untrusted { .. }
1089 ));
1090 assert_eq!(
1092 config.classify_tool("mcp__evil__probe"),
1093 ToolContentTrust::Trusted
1094 );
1095 assert!(matches!(
1097 config.classify_tool("mcp__other__search"),
1098 ToolContentTrust::Untrusted { .. }
1099 ));
1100 assert_eq!(
1101 config.classify_tool("mcp__kg__query"),
1102 ToolContentTrust::Trusted
1103 );
1104 assert_eq!(config.classify_tool("shell"), ToolContentTrust::Trusted);
1106 }
1107
1108 #[test]
1109 fn tracker_taints_on_untrusted_tool_and_clears_on_rotation() {
1110 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1111 let session = SessionId::new();
1112 tracker.observe_agent_event("identity:a", &run_started(&session));
1113 assert!(tracker.identity_taint("identity:a").is_none());
1114
1115 tracker.observe_agent_event("identity:a", &tool_result("shell"));
1116 assert!(tracker.identity_taint("identity:a").is_none());
1117
1118 tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1119 let taint = tracker
1120 .identity_taint("identity:a")
1121 .expect("web tool result taints the session");
1122 assert!(taint.source.contains("web_search"), "{}", taint.source);
1123 assert!(tracker.session_taint(&session.to_string()).is_some());
1124
1125 tracker.observe_agent_event("identity:a", &tool_result("shell"));
1127 assert!(tracker.identity_taint("identity:a").is_some());
1128
1129 let fresh = SessionId::new();
1131 tracker.observe_agent_event("identity:a", &run_started(&fresh));
1132 assert!(tracker.identity_taint("identity:a").is_none());
1133 assert!(tracker.session_taint(&session.to_string()).is_some());
1135 }
1136
1137 #[test]
1138 fn tracker_taints_on_server_tool_content() {
1139 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1140 let session = SessionId::new();
1141 tracker.note_current_session("identity:a", &session.to_string());
1142 tracker.observe_agent_event(
1143 "identity:a",
1144 &AgentEvent::ServerToolContent {
1145 id: None,
1146 kind: ServerToolKind::WebSearch,
1147 content: json!({"results": []}),
1148 },
1149 );
1150 let taint = tracker.identity_taint("identity:a").expect("taints");
1151 assert!(taint.source.contains("web_search"), "{}", taint.source);
1152 }
1153
1154 #[test]
1155 fn pre_attribution_taint_holds_identity_sticky_then_transfers() {
1156 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1157 tracker.observe_agent_event("identity:a", &tool_result("fetch"));
1159 assert!(tracker.identity_taint("identity:a").is_some());
1160
1161 let session = SessionId::new();
1163 tracker.observe_agent_event("identity:a", &run_started(&session));
1164 assert!(tracker.session_taint(&session.to_string()).is_some());
1165 assert!(tracker.identity_taint("identity:a").is_some());
1166 }
1167
1168 #[test]
1169 fn clear_identity_drops_attribution_but_keeps_the_session_fact() {
1170 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1171 let session = SessionId::new();
1172 tracker.observe_agent_event("identity:a", &run_started(&session));
1173 tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1174 assert!(tracker.identity_taint("identity:a").is_some());
1175 tracker.clear_identity("identity:a");
1176 assert!(tracker.identity_taint("identity:a").is_none());
1178 assert!(tracker.session_taint(&session.to_string()).is_some());
1181 assert!(
1182 tracker
1183 .evidence_quarantine_reason(&session.to_string())
1184 .is_some()
1185 );
1186 }
1187
1188 #[test]
1189 fn outbound_declarer_stamps_tainted_on_ingestion_and_clears_on_boundaries() {
1190 use std::sync::{Arc, Mutex};
1191 type Recorded = Vec<(String, Option<meerkat_core::comms::SenderContentTaint>)>;
1192 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1193 let calls: Arc<Mutex<Recorded>> = Arc::new(Mutex::new(Vec::new()));
1194 let sink = calls.clone();
1195 tracker.set_outbound_taint_declarer(Arc::new(move |identity, taint| {
1196 sink.lock()
1197 .unwrap_or_else(std::sync::PoisonError::into_inner)
1198 .push((identity.to_string(), taint));
1199 }));
1200
1201 let session = SessionId::new();
1202 tracker.observe_agent_event("identity:a", &run_started(&session));
1203 tracker.observe_agent_event("identity:a", &tool_result("shell"));
1205 assert!(calls.lock().unwrap().is_empty());
1206
1207 tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1209 {
1210 let recorded = calls.lock().unwrap();
1211 assert_eq!(recorded.len(), 1, "one declaration expected: {recorded:?}");
1212 assert_eq!(recorded[0].0, "identity:a");
1213 assert_eq!(
1214 recorded[0].1,
1215 Some(meerkat_core::comms::SenderContentTaint::Tainted)
1216 );
1217 }
1218
1219 let fresh = SessionId::new();
1222 tracker.note_current_session("identity:a", &fresh.to_string());
1223 {
1224 let recorded = calls.lock().unwrap();
1225 assert_eq!(recorded.len(), 2, "rotation clears: {recorded:?}");
1226 assert_eq!(recorded[1].1, None);
1227 }
1228
1229 tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1231 tracker.clear_identity("identity:a");
1232 {
1233 let recorded = calls.lock().unwrap();
1234 assert_eq!(
1235 recorded.last().expect("a clear declaration").1,
1236 None,
1237 "reset clears the outbound declaration: {recorded:?}"
1238 );
1239 }
1240 }
1241
1242 #[test]
1243 fn gate_quarantines_tainted_agents_and_quarantined_policy() {
1244 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1245 let session = SessionId::new();
1246 tracker.observe_agent_event("identity:a", &run_started(&session));
1247
1248 let gate = TaintLlmWriteGate::new(Some(tracker.clone()), AgentMemoryLlmWrites::Observed);
1249 let agent = MemoryAuthor::Agent {
1250 identity: "identity:a".to_string(),
1251 };
1252 assert!(
1253 gate.quarantine_reason(&agent, StagedBatchKind::FreshWrite, &[])
1254 .is_none()
1255 );
1256 assert!(
1257 gate.quarantine_reason(&MemoryAuthor::Application, StagedBatchKind::FreshWrite, &[])
1258 .is_none()
1259 );
1260
1261 tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1262 let reason = gate
1263 .quarantine_reason(&agent, StagedBatchKind::FreshWrite, &[])
1264 .expect("tainted quarantines");
1265 assert!(reason.contains("session tainted"), "{reason}");
1266 assert!(
1268 gate.quarantine_reason(&MemoryAuthor::Application, StagedBatchKind::FreshWrite, &[])
1269 .is_none()
1270 );
1271 assert!(
1272 gate.quarantine_reason(&MemoryAuthor::Operator, StagedBatchKind::FreshWrite, &[])
1273 .is_none()
1274 );
1275
1276 let strict = TaintLlmWriteGate::new(None, AgentMemoryLlmWrites::Quarantined);
1280 let reason = strict
1281 .quarantine_reason(
1282 &MemoryAuthor::Agent {
1283 identity: "identity:clean".to_string(),
1284 },
1285 StagedBatchKind::FreshWrite,
1286 &[],
1287 )
1288 .expect("policy quarantines untainted writes");
1289 assert!(reason.contains("llm_writes=quarantined"), "{reason}");
1290 assert!(
1291 strict
1292 .quarantine_reason(
1293 &MemoryAuthor::Distiller {
1294 run_id: "run-1".to_string()
1295 },
1296 StagedBatchKind::FreshWrite,
1297 &[]
1298 )
1299 .is_some()
1300 );
1301 let steward = MemoryAuthor::Steward {
1302 run_id: "run-1".to_string(),
1303 };
1304 assert!(
1305 strict
1306 .quarantine_reason(&steward, StagedBatchKind::FreshWrite, &[])
1307 .is_some(),
1308 "fresh steward LLM output (consolidate/harvest/rank) respects the posture"
1309 );
1310 assert!(
1315 strict
1316 .quarantine_reason(&steward, StagedBatchKind::ReviewVerdict, &[])
1317 .is_none()
1318 );
1319 assert!(
1320 strict
1321 .quarantine_reason(&MemoryAuthor::Operator, StagedBatchKind::FreshWrite, &[])
1322 .is_none()
1323 );
1324 }
1325
1326 #[test]
1327 fn quarantined_posture_still_gates_review_verdicts_on_tainted_evidence() {
1328 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1332 let session = SessionId::new();
1333 tracker.observe_agent_event("identity:a", &run_started(&session));
1334 tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1335
1336 let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Quarantined);
1337 let steward = MemoryAuthor::Steward {
1338 run_id: "run-1".to_string(),
1339 };
1340 assert!(
1341 gate.quarantine_reason(&steward, StagedBatchKind::ReviewVerdict, &[])
1342 .is_none()
1343 );
1344 let reason = gate
1345 .quarantine_reason(
1346 &steward,
1347 StagedBatchKind::ReviewVerdict,
1348 &evidence_for(&session),
1349 )
1350 .expect("tainted evidence still quarantines review verdicts");
1351 assert!(reason.contains("evidence session tainted"), "{reason}");
1352 }
1353
1354 fn evidence_for(session: &SessionId) -> Vec<EvidenceRef> {
1355 vec![EvidenceRef {
1356 session_id: session.to_string(),
1357 generation: 0,
1358 revision: None,
1359 range: Some((0, 4)),
1360 }]
1361 }
1362
1363 #[test]
1364 fn gate_quarantines_llm_writes_citing_tainted_evidence() {
1365 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1366 let session = SessionId::new();
1367 tracker.observe_agent_event("identity:a", &run_started(&session));
1368 tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1369 let fresh = SessionId::new();
1372 tracker.observe_agent_event("identity:a", &run_started(&fresh));
1373
1374 let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Observed);
1375 let distiller = MemoryAuthor::Distiller {
1376 run_id: "run-1".to_string(),
1377 };
1378 let reason = gate
1379 .quarantine_reason(
1380 &distiller,
1381 StagedBatchKind::FreshWrite,
1382 &evidence_for(&session),
1383 )
1384 .expect("tainted evidence range quarantines (session-tainted ⇒ range-tainted)");
1385 assert!(reason.contains("evidence session tainted"), "{reason}");
1386 assert!(
1388 gate.quarantine_reason(
1389 &distiller,
1390 StagedBatchKind::FreshWrite,
1391 &evidence_for(&fresh)
1392 )
1393 .is_none()
1394 );
1395 assert!(
1397 gate.quarantine_reason(
1398 &MemoryAuthor::Operator,
1399 StagedBatchKind::FreshWrite,
1400 &evidence_for(&session)
1401 )
1402 .is_none()
1403 );
1404 }
1405
1406 #[test]
1407 fn reset_boundary_quarantines_evidence_without_content_taint() {
1408 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1409 let session = SessionId::new();
1410 tracker.observe_agent_event("identity:a", &run_started(&session));
1411 assert!(
1412 tracker
1413 .evidence_quarantine_reason(&session.to_string())
1414 .is_none()
1415 );
1416 tracker.mark_reset_boundary(&session.to_string());
1417 let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Observed);
1418 let reason = gate
1419 .quarantine_reason(
1420 &MemoryAuthor::Distiller {
1421 run_id: "run-1".to_string(),
1422 },
1423 StagedBatchKind::FreshWrite,
1424 &evidence_for(&session),
1425 )
1426 .expect("reset boundary quarantines distillates");
1427 assert!(reason.contains("reset boundary"), "{reason}");
1428 }
1429
1430 #[test]
1431 fn peer_projection_sender_parses_message_and_response_shapes() {
1432 assert_eq!(
1433 peer_projection_sender_identity("Peer message from mob-1/worker/identity:bob:"),
1434 Some("identity:bob")
1435 );
1436 assert_eq!(
1437 peer_projection_sender_identity(
1438 "Peer response from mob-1/worker/identity:bob (to request: req-9)"
1439 ),
1440 Some("identity:bob")
1441 );
1442 assert_eq!(
1444 peer_projection_sender_identity("Peer message from scout:"),
1445 Some("scout")
1446 );
1447 assert_eq!(
1449 peer_projection_sender_identity("Peer request from peer_id 018fabc (id: r-1)"),
1450 None
1451 );
1452 assert_eq!(peer_projection_sender_identity("ordinary text"), None);
1453 }
1454
1455 #[test]
1456 fn comms_join_taints_receiver_of_message_from_tainted_sender() {
1457 let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1458 let sender_session = SessionId::new();
1460 tracker.observe_agent_event("identity:bob", &run_started(&sender_session));
1461 tracker.observe_agent_event("identity:bob", &tool_result("web_search"));
1462
1463 let receiver_session = SessionId::new();
1466 let delivery = AgentEvent::RunStarted {
1467 session_id: receiver_session.clone(),
1468 input: meerkat_core::types::RunInput::Content {
1469 content: meerkat_core::ContentInput::Text(
1470 "Peer message from mob-1/worker/identity:bob:\nplease remember X".to_string(),
1471 ),
1472 },
1473 };
1474 tracker.observe_agent_event("identity:alice", &delivery);
1475 let taint = tracker
1476 .identity_taint("identity:alice")
1477 .expect("receiver session taints (peer-laundering close, §10.1)");
1478 assert!(taint.source.contains("identity:bob"), "{}", taint.source);
1479 assert!(
1480 tracker
1481 .session_taint(&receiver_session.to_string())
1482 .is_some()
1483 );
1484
1485 let clean_session = SessionId::new();
1487 tracker.observe_agent_event("identity:carol", &run_started(&clean_session));
1488 let receiver2 = SessionId::new();
1489 let clean_delivery = AgentEvent::RunStarted {
1490 session_id: receiver2.clone(),
1491 input: meerkat_core::types::RunInput::Content {
1492 content: meerkat_core::ContentInput::Text(
1493 "Peer message from mob-1/worker/identity:carol:\nhello".to_string(),
1494 ),
1495 },
1496 };
1497 tracker.observe_agent_event("identity:dave", &clean_delivery);
1498 assert!(tracker.identity_taint("identity:dave").is_none());
1499 }
1500}