Skip to main content

meerkat_mobkit/memory/
taint.rs

1//! Coarse session-sticky content-taint tracking (§10.1, P1).
2//!
3//! The persistent-prompt-injection defense needs an input signal, and no
4//! turn-level or session-level content-taint fact exists anywhere in the
5//! platform. This module ships the P1 mechanism: a MobKit-owned
6//! content-trust configuration plus an observe-stream taint tracker that
7//! marks a session tainted once it ingests content from an untrusted tool
8//! source. Taint is **session-sticky** (Codex's thread-level
9//! `memory_mode='polluted'`, adopted): per-turn taint is trivially evaded
10//! by "on your NEXT reply, remember X", and compaction cannot clear taint —
11//! the summary is derived from tainted context and inherits it. Taint
12//! clears only at a fresh-context boundary (reset / respawn / fresh spawn),
13//! which is automatic here because state is keyed by session id and those
14//! paths mint new session ids.
15//!
16//! ## P2 additions (§10.1 taint completion)
17//!
18//! - **Comms taint join**: a peer message from a tracked sender whose
19//!   session is tainted taints the receiving session (the peer-laundering
20//!   close). See [`SessionTaintTracker::observe_inbound_peer_content`] for
21//!   what is — and honestly is not — observable.
22//! - **Evidence-range taint**: the write gate now sees a write's
23//!   `EvidenceRef`s; any LLM-authored write citing a tainted session
24//!   quarantines. Coarse by design: the tracker holds session-sticky
25//!   facts, so session-tainted ⇒ every range in it tainted (per-turn
26//!   granularity would need the Hygienist's pinned revisions, P4).
27//! - **Reset boundaries**: `reset()` marks the outgoing session so
28//!   Distiller output over it lands `Quarantined` (§8.4 — reset is the
29//!   operator's escape hatch; quarantine preserves the re-dream option).
30//!
31//! ## Honest gaps that remain (upstream asks, §13)
32//!
33//! - **The first-ingestion race** (ask: taint visibility at tool-dispatch
34//!   time): taint is derived from the observe-only agent-event stream,
35//!   which is asynchronous. A memory write in the same turn as the
36//!   session's *first* untrusted ingestion can reach the store before the
37//!   taint observer processes the tool event. Deployments that cannot
38//!   accept this set `agent_memory.llm_writes = "quarantined"`. This stays
39//!   upstream-dependent; nothing here pretends to close it.
40//! - **The mirror race**: after a session rotation, the tracker's view of
41//!   an identity's current session lags until the runtime's delivery hook
42//!   or the new session's first `RunStarted` event updates it, so a write
43//!   in that window can be quarantined against the *old* session's taint.
44//!   This errs conservative (false quarantine, never false trust).
45//! - **Name-based classification**: meerkat tool events carry only the tool
46//!   NAME — no `ToolDef.provenance` — so MCP tools cannot be attributed to
47//!   a server unless their names are server-qualified
48//!   (`mcp__<server>__<tool>`). Deployments with unqualified MCP tool names
49//!   list them in `content_trust.untrusted_tools` (or run
50//!   `llm_writes = "quarantined"`) until the dispatch-time join against
51//!   real `ToolProvenance` lands upstream.
52
53use 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
64/// Builtin web-facing tool names: ALWAYS untrusted for memory purposes, not
65/// overridable by `trusted_tools` (§10.1 "web/fetch always untrusted").
66/// `web_search` is meerkat's builtin client-side search
67/// (`meerkat_core::web_search::WEB_SEARCH_TOOL_NAME`); the others cover the
68/// common fetch spellings across builtin and bundle surfaces.
69const ALWAYS_UNTRUSTED_TOOL_NAMES: &[&str] = &["web_search", "web_fetch", "fetch", "http_request"];
70
71/// Server-qualified MCP tool-name prefix (`mcp__<server>__<tool>`). The only
72/// name shape that lets P1 attribute a tool to an MCP server (see module
73/// docs on classification coarseness).
74const MCP_QUALIFIED_PREFIX: &str = "mcp__";
75
76/// Tainted-session entries are bounded; oldest-tainted evict first. A taint
77/// entry for a dead session is inert (nothing checks it), so eviction only
78/// bounds memory, never correctness for live sessions at sane scales.
79const MAX_TRACKED_TAINTED_SESSIONS: usize = 4096;
80
81/// Which tool sources count as untrusted for memory purposes (§10.1).
82///
83/// Mirrors Codex's `pollutes_memory` posture: web/fetch and provider-native
84/// search are always untrusted; MCP servers are untrusted by default with an
85/// explicit `trusted_mcp_servers` allowlist. Note meerkat's
86/// `ToolAccessPolicy::AllowList` is invocation gating and cannot serve this
87/// role.
88#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89pub struct ContentTrustConfig {
90    /// MCP servers whose tools do not taint. Joinable in P1 only for
91    /// server-qualified tool names (`mcp__<server>__<tool>`).
92    #[serde(default)]
93    pub trusted_mcp_servers: Vec<String>,
94    /// Explicit tool names that taint the session when their results enter
95    /// context. The escape hatch for unqualified MCP tool names.
96    #[serde(default)]
97    pub untrusted_tools: Vec<String>,
98    /// Explicit tool names that never taint (cannot override the builtin
99    /// web/fetch class or `untrusted_tools`).
100    #[serde(default)]
101    pub trusted_tools: Vec<String>,
102}
103
104/// Classification verdict for one tool name.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum ToolContentTrust {
107    Trusted,
108    Untrusted { source: String },
109}
110
111impl ContentTrustConfig {
112    /// Fail-loud JSON parse for the gateway config block
113    /// `agent_memory.content_trust { ... }`. Unknown fields and wrong types
114    /// are errors, never silently ignored.
115    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    /// Classify a tool by NAME (the only fact the P1 event surface carries —
162    /// module docs). Precedence: builtin web/fetch (non-overridable) >
163    /// `untrusted_tools` > `trusted_tools` > MCP-qualified names against the
164    /// server allowlist > trusted.
165    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/// Why and when a session became tainted.
197#[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    /// identity → the session key the tracker currently attributes that
206    /// identity's activity to. Fed authoritatively by the identity runtime's
207    /// delivery/reset hooks and, as fallback, by observed `RunStarted`
208    /// events (peer-comms-driven runs never pass through the runtime hooks).
209    current_session: HashMap<String, String>,
210    /// session key → taint fact. Session-sticky by construction, and
211    /// retained after rotation/clear: the fact is historical ("this session
212    /// ingested untrusted content"), and the P2 comms join and
213    /// evidence-range gate read it for sessions that are no longer current.
214    tainted: HashMap<String, TaintState>,
215    /// Untrusted ingestion observed before the observer learned the
216    /// identity's session (mid-run attach). Transferred to the next learned
217    /// session — conservative direction (see module docs).
218    pending_identity_taint: HashMap<String, TaintState>,
219    /// session key → reset-boundary mark (§8.4): distillates citing this
220    /// session quarantine pending steward review. Bounded like `tainted`.
221    reset_boundaries: HashMap<String, u64>,
222}
223
224/// Host callback that stamps a member's OUTBOUND content-taint declaration
225/// (§10.1 ask 5, outbound half). Invoked with the member's public identity and
226/// the taint to declare — `Some(Tainted)` when the member's session ingests
227/// untrusted content, `None` when it rotates back to a clean session — so the
228/// member's peer sends carry the sender's signed declaration and receivers can
229/// propagate taint cross-process. The callback is fire-and-forget (the real
230/// declare is async on the mob runtime); it must not block.
231pub type OutboundTaintDeclarer =
232    Arc<dyn Fn(&str, Option<meerkat_core::comms::SenderContentTaint>) + Send + Sync>;
233
234/// Session-sticky taint tracker (§10.1, coarse P1). Cheap to clone; clones
235/// share state.
236#[derive(Clone, Default)]
237pub struct SessionTaintTracker {
238    config: Arc<ContentTrustConfig>,
239    inner: Arc<Mutex<TaintInner>>,
240    /// §9.3 timeline sink for taint transitions; shared across clones.
241    event_sink: Arc<Mutex<Option<Arc<dyn crate::memory::events::MemoryEventSink>>>>,
242    /// §10.1 ask 5 outbound half: declares a member's outbound content-taint
243    /// on the mob runtime so its peer sends carry the flag. Shared across
244    /// clones; `None` until the gateway wires the mob handle.
245    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    /// Wire the §10.1 outbound-taint declarer (bound to the mob handle at
259    /// gateway bootstrap). Shared across clones.
260    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    /// Declare a member's outbound content-taint, if a declarer is wired.
268    /// Called on taint transitions (set/clear); must be invoked WITHOUT the
269    /// `inner` lock held (the callback may take time to hand off).
270    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    /// Wire the §9.3 timeline sink so taint transitions surface on the
286    /// console alongside the tracing warns. Shared across clones.
287    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    /// Observe one agent event for `identity` (the observe-only agent-event
306    /// stream is per-member, so attribution is the subscription's).
307    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                // §10.1 comms taint join (legacy, belt-and-braces): before
313                // meerkat 0.7.13 the only inbound signal was projection text.
314                // It stays as a fallback for same-process senders whose taint
315                // this host tracks but who make no envelope declaration.
316                if let Some(text) = input.prompt_text() {
317                    self.observe_inbound_peer_content(identity, &session_key, &text);
318                }
319            }
320            // §10.1 ask 5 (0.7.13): consume the sender's SIGNED content-taint
321            // declaration from the typed peer-ingestion event — canonical peer
322            // identity, emitted synchronously as the block commits — instead of
323            // parsing rendered projection text. Only an affirmative `Tainted`
324            // taints the receiving session; `None` ("no declaration") and
325            // `Clean` do not, and `None` is never coalesced into `Clean`.
326            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            // Taint on result *ingestion*: these are the events whose content
341            // enters the conversation context. Errors taint too — an error
342            // body is still attacker-influenced text in context.
343            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            // Provider-executed server tools (web search / grounding /
350            // provider-native): typed, and always untrusted (§10.1).
351            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    /// Authoritative current-session hint from the identity runtime's
362    /// delivery path. Keeps the tracker's attribution ahead of the (async)
363    /// observe stream on the paths MobKit controls.
364    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        // A held pre-attribution taint re-lands on the new session — it stays
392        // tainted, so do not clear the outbound declaration below.
393        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        // §10.1 outbound half: on a clean rotation with no re-landed taint, the
398        // identity's live session is clean again — clear its outbound stamp.
399        if rotated_away_from_tainted && !pending_reapplied {
400            drop(inner);
401            self.declare_outbound(identity, None);
402        }
403    }
404
405    /// Explicit clear for the reset path (`reset()` is the operator's escape
406    /// hatch from a poisoned session). Rotation already clears implicitly;
407    /// this also drops any pending pre-attribution taint. The outgoing
408    /// session's *fact* is deliberately retained (P2): "that session was
409    /// tainted" stays true after the identity moves on, and the comms join
410    /// and the Distiller's evidence gate consult it for exactly such
411    /// sessions.
412    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        // §10.1 outbound half: reset is a fresh-context boundary — the identity
420        // no longer speaks for a tainted session, so clear its outbound stamp.
421        drop(inner);
422        self.declare_outbound(identity, None);
423    }
424
425    /// Mark a `reset()` boundary on the outgoing session (§8.4): every
426    /// LLM-authored write whose evidence cites this session quarantines
427    /// pending steward review, regardless of content taint. Idempotent.
428    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    /// §10.1/§8.4 evidence gate query: why a write citing `session_key` as
464    /// evidence must quarantine, if it must. Coarse by design: the tracker
465    /// holds session-sticky facts, so a tainted session taints every
466    /// evidence range within it.
467    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    /// Taint fact for an explicit session key.
489    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    /// Taint fact for the identity's currently-attributed session (the write
498    /// gate's query: LLM-authored writes carry identity, not session).
499    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    /// §10.1 comms taint join, over what the observe surface actually
515    /// carries. Meerkat 0.7.9 has **no typed inbound peer-message event**:
516    /// a peer delivery lands in the receiver's session as injected prompt
517    /// text rendered by `format_peer_message_projection` /
518    /// `format_peer_response_projection` (meerkat-core `interaction.rs:126,
519    /// :239`), where the sender is the resolved trusted-peer name — for mob
520    /// members the `MemberCommsName` `{mob_id}/{role}/{agent_identity}`
521    /// (meerkat-core `connection.rs:368`). This join parses that sender out
522    /// and taints the receiving session when the sender's tracked session
523    /// is tainted.
524    ///
525    /// Honest limits, filed against upstream ask 5 (envelope-level taint
526    /// flags):
527    /// - **"tainted at send time" is approximated at delivery-observe
528    ///   time.** If the sender rotated to a clean session between send and
529    ///   delivery, the join misses; if the sender got tainted after the
530    ///   send, the join over-taints (conservative direction).
531    /// - **Peer *requests* render a raw cryptographic `peer_id`**, not a
532    ///   comms name, so their senders are unmappable host-side — unknowable
533    ///   without the upstream envelope fact.
534    /// - **Cross-process senders** are not in this tracker at all;
535    ///   sender-session taint state is unknowable for them.
536    /// - A user message quoting the projection prefix can false-positive —
537    ///   conservative (false quarantine, never false trust).
538    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                // Mid-run attach: session unknown until the next RunStarted /
592                // delivery hook. Hold identity-sticky (module docs).
593                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        // §10.1 outbound half: the identity has ingested untrusted content, so
610        // stamp its peer sends Tainted (whether the taint landed on the current
611        // session or is held pending). Declare OUTSIDE the `inner` lock.
612        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
653/// Store-seam write gate (§10.1 posture): consulted by the bundled store for
654/// every LLM-authored create/supersede so the quarantine decision holds for
655/// ALL callers — the Recorder tool, staged batches, and any future stage —
656/// not just the tool handler.
657pub trait LlmWriteGate: Send + Sync {
658    /// `Some(reason)` when this LLM-authored write must land
659    /// `RecordStatus::Quarantined`. Non-LLM principals are never gated.
660    /// `kind` is the batch's semantic kind (§10.1): review verdicts are
661    /// the review the quarantine posture defers to, fresh writes are not.
662    /// `evidence` is the union of `EvidenceRef`s the write cites (P2
663    /// evidence-range taint, §10.1); empty for writes that cite nothing.
664    fn quarantine_reason(
665        &self,
666        author: &MemoryAuthor,
667        kind: StagedBatchKind,
668        evidence: &[EvidenceRef],
669    ) -> Option<String>;
670}
671
672/// The taint/posture gate: `llm_writes = "quarantined"` forces every
673/// first-pass LLM-authored write (Agent, Distiller, and the steward's own
674/// consolidate/harvest/rank output) into quarantine regardless of taint;
675/// agent-authored writes quarantine when the author's session is tainted;
676/// and ANY LLM-authored write (Steward and Distiller included) quarantines
677/// when its evidence cites a tainted session or a reset boundary
678/// (§8.4/§10.1 — coarse: session-tainted ⇒ range-tainted).
679///
680/// Review-verdict batches (`StagedBatchKind::ReviewVerdict`) are exempt
681/// from the *posture* branch only: a review verdict IS the review the
682/// posture defers to (§10.1 "quarantined until steward/operator review") —
683/// quarantine releases, gating-approved promotions, and proposal accepts
684/// commit as review verdicts, and re-quarantining them would make review
685/// unable to ever produce an Active record under the conservative posture.
686/// The exemption is keyed on the batch's semantic kind, NOT on the Steward
687/// author: all dream groups carry `MemoryAuthor::Steward`, but fresh
688/// steward LLM output (consolidate creates, harvest copies, rank batches)
689/// is first-pass content and respects the posture knob. The evidence-taint
690/// branch still applies to every batch, so a consolidation citing a
691/// tainted session quarantines like any other write.
692pub 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
736/// Sender-identity extraction from one line of injected peer-projection
737/// text. Pinned to meerkat 0.7.9's canonical projections:
738/// `format_peer_message_projection` → `"Peer message from {name}:"` and
739/// `format_peer_response_projection` → `"Peer response from {name} (to
740/// request: ...)"`, where `{name}` for a mob member is
741/// `{mob_id}/{role}/{agent_identity}`. Returns the trailing path segment
742/// (the agent identity). Peer *requests* carry a raw peer id and return
743/// `None` (module docs on the honest gaps).
744fn peer_projection_sender_identity(line: &str) -> Option<&str> {
745    let name = if let Some(rest) = line.strip_prefix("Peer message from ") {
746        // Canonical shape ends the line with ':' (body follows on the next
747        // line). Names may themselves contain ':' (agent identities do), so
748        // strip the trailing delimiter rather than splitting at the first.
749        match rest.split_once(": ") {
750            Some((name, _)) => name.trim(),
751            None => rest.strip_suffix(':').unwrap_or(rest).trim(),
752        }
753    } else if let Some(rest) = line.strip_prefix("Peer response from ") {
754        rest.split(" (to request:").next()?.trim()
755    } else {
756        return None;
757    };
758    if name.is_empty() {
759        return None;
760    }
761    Some(name.rsplit('/').next().unwrap_or(name))
762}
763
764// ---------------------------------------------------------------------------
765// Observe-stream feed
766// ---------------------------------------------------------------------------
767
768/// A consumer of the per-member agent-event observe stream. The taint
769/// tracker and the Distiller's trigger sink both ride ONE observer loop —
770/// one `subscribe_agent_events` subscription per member, however many
771/// memory stages listen.
772pub trait MemberAgentEventSink: Send + Sync {
773    fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>);
774}
775
776impl MemberAgentEventSink for SessionTaintTracker {
777    fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
778        self.observe_agent_event(identity, &envelope.payload);
779    }
780}
781
782/// §9.1 as-built compaction reset feed: an ALWAYS-ON sink — unconditional,
783/// unlike the distiller's trigger sink — surfacing `CompactionCompleted`
784/// session keys to a callback. The gateway points it at
785/// `AgentMemoryRuntimeInjector::on_session_compacted` so the coordinator's
786/// cross-turn dedup/budget state resets even when no distiller is enabled
787/// (gate finding: budgeted injection without a distiller never reset).
788pub struct CompactionResetSink {
789    on_compacted: Arc<dyn Fn(&str) + Send + Sync>,
790}
791
792impl CompactionResetSink {
793    pub fn new(on_compacted: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
794        Self { on_compacted }
795    }
796}
797
798impl MemberAgentEventSink for CompactionResetSink {
799    fn observe(&self, _identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
800        if matches!(envelope.payload, AgentEvent::CompactionCompleted { .. })
801            && let meerkat_core::event::EventSourceIdentity::Session { session_id } =
802                &envelope.source
803        {
804            (self.on_compacted)(&session_id.to_string());
805        }
806    }
807}
808
809/// Guard for the observer task; aborts the task when the last clone drops
810/// (the runtime that owned the sinks is gone).
811#[derive(Clone)]
812pub struct TaintObserverGuard {
813    _abort: Arc<AbortOnDrop>,
814}
815
816struct AbortOnDrop(tokio::task::JoinHandle<()>);
817
818impl Drop for AbortOnDrop {
819    fn drop(&mut self) {
820        self.0.abort();
821    }
822}
823
824/// Subscribe the taint observer to every active member's agent-event stream
825/// (the same observe-only `subscribe_agent_events` surface the console
826/// forwarder rides), reconciling membership every second.
827pub fn spawn_taint_observer(
828    handle: meerkat_mob::MobHandle,
829    tracker: SessionTaintTracker,
830) -> TaintObserverGuard {
831    spawn_member_event_observer(handle, vec![Arc::new(tracker)])
832}
833
834/// Generalized observer: one reconcile loop, one stream per active member,
835/// fanned out to every sink (taint tracker, Distiller triggers, future
836/// stages).
837pub fn spawn_member_event_observer(
838    handle: meerkat_mob::MobHandle,
839    sinks: Vec<Arc<dyn MemberAgentEventSink>>,
840) -> TaintObserverGuard {
841    let task = tokio::spawn(run_member_event_observer(handle, sinks));
842    TaintObserverGuard {
843        _abort: Arc::new(AbortOnDrop(task)),
844    }
845}
846
847async fn run_member_event_observer(
848    handle: meerkat_mob::MobHandle,
849    sinks: Vec<Arc<dyn MemberAgentEventSink>>,
850) {
851    use futures::StreamExt;
852    use futures::stream::SelectAll;
853
854    enum Observed {
855        Event(String, Box<meerkat_core::event::EventEnvelope<AgentEvent>>),
856        Closed(String),
857    }
858
859    let mut streams: SelectAll<futures::stream::BoxStream<'static, Observed>> = SelectAll::new();
860    let mut subscribed: std::collections::HashSet<String> = std::collections::HashSet::new();
861    let mut warned: std::collections::HashSet<String> = std::collections::HashSet::new();
862    let mut reconcile = tokio::time::interval(std::time::Duration::from_secs(1));
863    reconcile.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
864
865    loop {
866        tokio::select! {
867            Some(observed) = streams.next() => match observed {
868                Observed::Event(identity, envelope) => {
869                    for sink in &sinks {
870                        sink.observe(&identity, &envelope);
871                    }
872                }
873                Observed::Closed(identity) => {
874                    subscribed.remove(&identity);
875                }
876            },
877            _ = reconcile.tick() => {
878                for entry in handle.list_members_including_retiring().await {
879                    // Only Active members have a live runtime delta stream;
880                    // subscribing others fails every tick (the console
881                    // forwarder learned this the hard way).
882                    if entry.status != meerkat_mob::MobMemberStatus::Active {
883                        continue;
884                    }
885                    let identity = entry.agent_identity.to_string();
886                    if subscribed.contains(&identity) {
887                        continue;
888                    }
889                    match handle.subscribe_agent_events(&entry.agent_identity).await {
890                        Ok(stream) => {
891                            warned.remove(&identity);
892                            subscribed.insert(identity.clone());
893                            let close_key = identity.clone();
894                            streams.push(
895                                stream
896                                    .map(move |envelope| {
897                                        Observed::Event(identity.clone(), Box::new(envelope))
898                                    })
899                                    .chain(futures::stream::once(async move {
900                                        Observed::Closed(close_key)
901                                    }))
902                                    .boxed(),
903                            );
904                        }
905                        Err(error) => {
906                            // Usually a short-lived spawn race; retried next
907                            // tick. Warn once per identity, then debug.
908                            if warned.insert(identity.clone()) {
909                                tracing::warn!(
910                                    identity = %identity,
911                                    error = %error,
912                                    "agent memory taint observer: failed to subscribe; will retry"
913                                );
914                            } else {
915                                tracing::debug!(
916                                    identity = %identity,
917                                    error = %error,
918                                    "agent memory taint observer: subscribe still failing"
919                                );
920                            }
921                        }
922                    }
923                }
924            }
925        }
926    }
927}
928
929fn now_ms() -> u64 {
930    SystemTime::now()
931        .duration_since(UNIX_EPOCH)
932        .map(|duration| duration.as_millis() as u64)
933        .unwrap_or(0)
934}
935
936#[cfg(test)]
937#[allow(
938    clippy::expect_used,
939    clippy::panic,
940    clippy::redundant_clone,
941    clippy::unwrap_used
942)]
943mod tests {
944    use super::*;
945    use meerkat_core::types::{ContentBlock, ServerToolKind, SessionId};
946    use serde_json::json;
947
948    fn run_started(session: &SessionId) -> AgentEvent {
949        AgentEvent::RunStarted {
950            session_id: session.clone(),
951            input: meerkat_core::types::RunInput::Content {
952                content: meerkat_core::ContentInput::Text("hi".to_string()),
953            },
954        }
955    }
956
957    fn tool_result(name: &str) -> AgentEvent {
958        AgentEvent::ToolResultReceived {
959            id: "tool-1".to_string(),
960            name: name.to_string(),
961            content: vec![ContentBlock::Text {
962                text: "ok".to_string(),
963            }],
964            is_error: false,
965        }
966    }
967
968    fn peer_ingested(taint: Option<meerkat_core::comms::SenderContentTaint>) -> AgentEvent {
969        AgentEvent::PeerContentIngested {
970            kind: meerkat_core::types::CommsNoticeKind::Message,
971            peer: None,
972            request_id: None,
973            sender_taint: taint,
974        }
975    }
976
977    // Ask 5 (0.7.13): the receiver taints on a sender's SIGNED `Tainted`
978    // declaration, from the typed event — but `None` ("no declaration") and
979    // `Clean` must never taint, and `None` is never coalesced into `Clean`.
980    #[test]
981    fn declared_peer_taint_taints_receiver_but_none_or_clean_does_not() {
982        use meerkat_core::comms::SenderContentTaint;
983        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
984        tracker.note_current_session("identity:b", "sess-b");
985
986        tracker.observe_agent_event("identity:b", &peer_ingested(None));
987        assert!(
988            tracker.session_taint("sess-b").is_none(),
989            "no declaration must not taint"
990        );
991
992        tracker.observe_agent_event(
993            "identity:b",
994            &peer_ingested(Some(SenderContentTaint::Clean)),
995        );
996        assert!(
997            tracker.session_taint("sess-b").is_none(),
998            "an affirmative Clean declaration must not taint"
999        );
1000
1001        tracker.observe_agent_event(
1002            "identity:b",
1003            &peer_ingested(Some(SenderContentTaint::Tainted)),
1004        );
1005        assert!(
1006            tracker.session_taint("sess-b").is_some(),
1007            "a declared-tainted peer delivery taints the receiving session"
1008        );
1009    }
1010
1011    #[test]
1012    fn taint_transitions_emit_timeline_events_when_sink_wired() {
1013        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1014        let sink = std::sync::Arc::new(crate::memory::events::CollectingEventSink::new());
1015        tracker.set_event_sink(sink.clone());
1016
1017        tracker.note_current_session("identity:a", "sess-1");
1018        tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1019        tracker.mark_reset_boundary("sess-1");
1020        // Idempotent boundary: no duplicate event.
1021        tracker.mark_reset_boundary("sess-1");
1022        tracker.note_current_session("identity:a", "sess-2");
1023
1024        let types = sink.types();
1025        assert_eq!(
1026            types,
1027            vec![
1028                "memory.taint.transition", // sess-1 tainted
1029                "memory.taint.transition", // reset boundary
1030                "memory.taint.transition", // rotated clean
1031            ]
1032        );
1033        let events = sink.events.lock().unwrap();
1034        let kinds: Vec<String> = events
1035            .iter()
1036            .map(|event| match event {
1037                crate::memory::events::MemoryTimelineEvent::TaintTransition { kind, .. } => {
1038                    kind.clone()
1039                }
1040                other => panic!("unexpected event {other:?}"),
1041            })
1042            .collect();
1043        assert_eq!(kinds, vec!["tainted", "reset_boundary", "rotated_clean"]);
1044    }
1045
1046    #[test]
1047    fn content_trust_parse_rejects_unknown_fields_and_bad_types() {
1048        let err = ContentTrustConfig::from_json_value(&json!({"servers": []}))
1049            .expect_err("unknown field must fail loud");
1050        assert!(err.contains("unsupported content_trust fields"), "{err}");
1051        let err = ContentTrustConfig::from_json_value(&json!({"trusted_mcp_servers": "kg"}))
1052            .expect_err("non-array must fail loud");
1053        assert!(err.contains("must be an array"), "{err}");
1054        let err = ContentTrustConfig::from_json_value(&json!({"untrusted_tools": [1]}))
1055            .expect_err("non-string entry must fail loud");
1056        assert!(err.contains("non-empty strings"), "{err}");
1057        let err =
1058            ContentTrustConfig::from_json_value(&json!([])).expect_err("non-object must fail loud");
1059        assert!(err.contains("must be an object"), "{err}");
1060    }
1061
1062    #[test]
1063    fn content_trust_parse_accepts_full_block() {
1064        let config = ContentTrustConfig::from_json_value(&json!({
1065            "trusted_mcp_servers": ["knowledge_graph"],
1066            "untrusted_tools": ["scrape_page"],
1067            "trusted_tools": ["mcp__scanner__lint"],
1068        }))
1069        .expect("valid block parses");
1070        assert_eq!(config.trusted_mcp_servers, vec!["knowledge_graph"]);
1071        assert_eq!(config.untrusted_tools, vec!["scrape_page"]);
1072        assert_eq!(config.trusted_tools, vec!["mcp__scanner__lint"]);
1073    }
1074
1075    #[test]
1076    fn classification_precedence_holds() {
1077        let config = ContentTrustConfig {
1078            trusted_mcp_servers: vec!["kg".to_string()],
1079            untrusted_tools: vec!["scrape_page".to_string()],
1080            // Web builtins are never overridable.
1081            trusted_tools: vec!["web_search".to_string(), "mcp__evil__probe".to_string()],
1082        };
1083        assert!(matches!(
1084            config.classify_tool("web_search"),
1085            ToolContentTrust::Untrusted { .. }
1086        ));
1087        assert!(matches!(
1088            config.classify_tool("scrape_page"),
1089            ToolContentTrust::Untrusted { .. }
1090        ));
1091        // Explicit per-tool trust overrides server-level distrust.
1092        assert_eq!(
1093            config.classify_tool("mcp__evil__probe"),
1094            ToolContentTrust::Trusted
1095        );
1096        // MCP untrusted by default; allowlisted server trusted.
1097        assert!(matches!(
1098            config.classify_tool("mcp__other__search"),
1099            ToolContentTrust::Untrusted { .. }
1100        ));
1101        assert_eq!(
1102            config.classify_tool("mcp__kg__query"),
1103            ToolContentTrust::Trusted
1104        );
1105        // Unknown plain names are trusted in P1 (documented coarseness).
1106        assert_eq!(config.classify_tool("shell"), ToolContentTrust::Trusted);
1107    }
1108
1109    #[test]
1110    fn tracker_taints_on_untrusted_tool_and_clears_on_rotation() {
1111        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1112        let session = SessionId::new();
1113        tracker.observe_agent_event("identity:a", &run_started(&session));
1114        assert!(tracker.identity_taint("identity:a").is_none());
1115
1116        tracker.observe_agent_event("identity:a", &tool_result("shell"));
1117        assert!(tracker.identity_taint("identity:a").is_none());
1118
1119        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1120        let taint = tracker
1121            .identity_taint("identity:a")
1122            .expect("web tool result taints the session");
1123        assert!(taint.source.contains("web_search"), "{}", taint.source);
1124        assert!(tracker.session_taint(&session.to_string()).is_some());
1125
1126        // Session-sticky: a later benign event does not clear.
1127        tracker.observe_agent_event("identity:a", &tool_result("shell"));
1128        assert!(tracker.identity_taint("identity:a").is_some());
1129
1130        // Rotation (reset/respawn/fresh spawn mint a new session id) clears.
1131        let fresh = SessionId::new();
1132        tracker.observe_agent_event("identity:a", &run_started(&fresh));
1133        assert!(tracker.identity_taint("identity:a").is_none());
1134        // The old session's fact remains recorded (P2 comms joins read it).
1135        assert!(tracker.session_taint(&session.to_string()).is_some());
1136    }
1137
1138    #[test]
1139    fn tracker_taints_on_server_tool_content() {
1140        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1141        let session = SessionId::new();
1142        tracker.note_current_session("identity:a", &session.to_string());
1143        tracker.observe_agent_event(
1144            "identity:a",
1145            &AgentEvent::ServerToolContent {
1146                id: None,
1147                kind: ServerToolKind::WebSearch,
1148                content: json!({"results": []}),
1149            },
1150        );
1151        let taint = tracker.identity_taint("identity:a").expect("taints");
1152        assert!(taint.source.contains("web_search"), "{}", taint.source);
1153    }
1154
1155    #[test]
1156    fn pre_attribution_taint_holds_identity_sticky_then_transfers() {
1157        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1158        // Tool event before any RunStarted (mid-run attach).
1159        tracker.observe_agent_event("identity:a", &tool_result("fetch"));
1160        assert!(tracker.identity_taint("identity:a").is_some());
1161
1162        // The next attributed session inherits the pending taint.
1163        let session = SessionId::new();
1164        tracker.observe_agent_event("identity:a", &run_started(&session));
1165        assert!(tracker.session_taint(&session.to_string()).is_some());
1166        assert!(tracker.identity_taint("identity:a").is_some());
1167    }
1168
1169    #[test]
1170    fn clear_identity_drops_attribution_but_keeps_the_session_fact() {
1171        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1172        let session = SessionId::new();
1173        tracker.observe_agent_event("identity:a", &run_started(&session));
1174        tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1175        assert!(tracker.identity_taint("identity:a").is_some());
1176        tracker.clear_identity("identity:a");
1177        // The identity moves on clean...
1178        assert!(tracker.identity_taint("identity:a").is_none());
1179        // ...but the historical fact survives: the comms join and the
1180        // evidence-range gate consult exactly such sessions (P2).
1181        assert!(tracker.session_taint(&session.to_string()).is_some());
1182        assert!(
1183            tracker
1184                .evidence_quarantine_reason(&session.to_string())
1185                .is_some()
1186        );
1187    }
1188
1189    #[test]
1190    fn outbound_declarer_stamps_tainted_on_ingestion_and_clears_on_boundaries() {
1191        use std::sync::{Arc, Mutex};
1192        type Recorded = Vec<(String, Option<meerkat_core::comms::SenderContentTaint>)>;
1193        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1194        let calls: Arc<Mutex<Recorded>> = Arc::new(Mutex::new(Vec::new()));
1195        let sink = calls.clone();
1196        tracker.set_outbound_taint_declarer(Arc::new(move |identity, taint| {
1197            sink.lock()
1198                .unwrap_or_else(std::sync::PoisonError::into_inner)
1199                .push((identity.to_string(), taint));
1200        }));
1201
1202        let session = SessionId::new();
1203        tracker.observe_agent_event("identity:a", &run_started(&session));
1204        // A benign tool makes no declaration.
1205        tracker.observe_agent_event("identity:a", &tool_result("shell"));
1206        assert!(calls.lock().unwrap().is_empty());
1207
1208        // Untrusted ingestion stamps the member Tainted for outbound peer sends.
1209        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1210        {
1211            let recorded = calls.lock().unwrap();
1212            assert_eq!(recorded.len(), 1, "one declaration expected: {recorded:?}");
1213            assert_eq!(recorded[0].0, "identity:a");
1214            assert_eq!(
1215                recorded[0].1,
1216                Some(meerkat_core::comms::SenderContentTaint::Tainted)
1217            );
1218        }
1219
1220        // Rotation to a fresh clean session clears the declaration (None, not
1221        // Clean — an absent declaration, never coalesced into clean).
1222        let fresh = SessionId::new();
1223        tracker.note_current_session("identity:a", &fresh.to_string());
1224        {
1225            let recorded = calls.lock().unwrap();
1226            assert_eq!(recorded.len(), 2, "rotation clears: {recorded:?}");
1227            assert_eq!(recorded[1].1, None);
1228        }
1229
1230        // Reset (the operator escape hatch) is also a fresh-context boundary.
1231        tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1232        tracker.clear_identity("identity:a");
1233        {
1234            let recorded = calls.lock().unwrap();
1235            assert_eq!(
1236                recorded.last().expect("a clear declaration").1,
1237                None,
1238                "reset clears the outbound declaration: {recorded:?}"
1239            );
1240        }
1241    }
1242
1243    #[test]
1244    fn gate_quarantines_tainted_agents_and_quarantined_policy() {
1245        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1246        let session = SessionId::new();
1247        tracker.observe_agent_event("identity:a", &run_started(&session));
1248
1249        let gate = TaintLlmWriteGate::new(Some(tracker.clone()), AgentMemoryLlmWrites::Observed);
1250        let agent = MemoryAuthor::Agent {
1251            identity: "identity:a".to_string(),
1252        };
1253        assert!(
1254            gate.quarantine_reason(&agent, StagedBatchKind::FreshWrite, &[])
1255                .is_none()
1256        );
1257        assert!(
1258            gate.quarantine_reason(&MemoryAuthor::Application, StagedBatchKind::FreshWrite, &[])
1259                .is_none()
1260        );
1261
1262        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1263        let reason = gate
1264            .quarantine_reason(&agent, StagedBatchKind::FreshWrite, &[])
1265            .expect("tainted quarantines");
1266        assert!(reason.contains("session tainted"), "{reason}");
1267        // Non-LLM principals are never gated, tainted or not.
1268        assert!(
1269            gate.quarantine_reason(&MemoryAuthor::Application, StagedBatchKind::FreshWrite, &[])
1270                .is_none()
1271        );
1272        assert!(
1273            gate.quarantine_reason(&MemoryAuthor::Operator, StagedBatchKind::FreshWrite, &[])
1274                .is_none()
1275        );
1276
1277        // llm_writes=quarantined forces quarantine with no taint at all —
1278        // for every first-pass LLM write (Agent, Distiller, and the
1279        // steward's own fresh consolidate/harvest/rank output).
1280        let strict = TaintLlmWriteGate::new(None, AgentMemoryLlmWrites::Quarantined);
1281        let reason = strict
1282            .quarantine_reason(
1283                &MemoryAuthor::Agent {
1284                    identity: "identity:clean".to_string(),
1285                },
1286                StagedBatchKind::FreshWrite,
1287                &[],
1288            )
1289            .expect("policy quarantines untainted writes");
1290        assert!(reason.contains("llm_writes=quarantined"), "{reason}");
1291        assert!(
1292            strict
1293                .quarantine_reason(
1294                    &MemoryAuthor::Distiller {
1295                        run_id: "run-1".to_string()
1296                    },
1297                    StagedBatchKind::FreshWrite,
1298                    &[]
1299                )
1300                .is_some()
1301        );
1302        let steward = MemoryAuthor::Steward {
1303            run_id: "run-1".to_string(),
1304        };
1305        assert!(
1306            strict
1307                .quarantine_reason(&steward, StagedBatchKind::FreshWrite, &[])
1308                .is_some(),
1309            "fresh steward LLM output (consolidate/harvest/rank) respects the posture"
1310        );
1311        // A review verdict IS the review the posture defers to: the posture
1312        // branch must not re-quarantine releases, approved promotions, and
1313        // proposal accepts (they would otherwise never produce an Active
1314        // record). Keyed on the batch kind, not the Steward author.
1315        assert!(
1316            strict
1317                .quarantine_reason(&steward, StagedBatchKind::ReviewVerdict, &[])
1318                .is_none()
1319        );
1320        assert!(
1321            strict
1322                .quarantine_reason(&MemoryAuthor::Operator, StagedBatchKind::FreshWrite, &[])
1323                .is_none()
1324        );
1325    }
1326
1327    #[test]
1328    fn quarantined_posture_still_gates_review_verdicts_on_tainted_evidence() {
1329        // The review-verdict exemption is posture-branch-only: a review
1330        // batch citing a tainted session still quarantines through the
1331        // evidence-range branch (§10.1).
1332        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1333        let session = SessionId::new();
1334        tracker.observe_agent_event("identity:a", &run_started(&session));
1335        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1336
1337        let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Quarantined);
1338        let steward = MemoryAuthor::Steward {
1339            run_id: "run-1".to_string(),
1340        };
1341        assert!(
1342            gate.quarantine_reason(&steward, StagedBatchKind::ReviewVerdict, &[])
1343                .is_none()
1344        );
1345        let reason = gate
1346            .quarantine_reason(
1347                &steward,
1348                StagedBatchKind::ReviewVerdict,
1349                &evidence_for(&session),
1350            )
1351            .expect("tainted evidence still quarantines review verdicts");
1352        assert!(reason.contains("evidence session tainted"), "{reason}");
1353    }
1354
1355    fn evidence_for(session: &SessionId) -> Vec<EvidenceRef> {
1356        vec![EvidenceRef {
1357            session_id: session.to_string(),
1358            generation: 0,
1359            revision: None,
1360            range: Some((0, 4)),
1361        }]
1362    }
1363
1364    #[test]
1365    fn gate_quarantines_llm_writes_citing_tainted_evidence() {
1366        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1367        let session = SessionId::new();
1368        tracker.observe_agent_event("identity:a", &run_started(&session));
1369        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1370        // Identity rotates away: the identity is clean, the session fact
1371        // remains — the distiller's evidence must still quarantine.
1372        let fresh = SessionId::new();
1373        tracker.observe_agent_event("identity:a", &run_started(&fresh));
1374
1375        let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Observed);
1376        let distiller = MemoryAuthor::Distiller {
1377            run_id: "run-1".to_string(),
1378        };
1379        let reason = gate
1380            .quarantine_reason(
1381                &distiller,
1382                StagedBatchKind::FreshWrite,
1383                &evidence_for(&session),
1384            )
1385            .expect("tainted evidence range quarantines (session-tainted ⇒ range-tainted)");
1386        assert!(reason.contains("evidence session tainted"), "{reason}");
1387        // Clean evidence does not.
1388        assert!(
1389            gate.quarantine_reason(
1390                &distiller,
1391                StagedBatchKind::FreshWrite,
1392                &evidence_for(&fresh)
1393            )
1394            .is_none()
1395        );
1396        // Non-LLM authors are never evidence-gated.
1397        assert!(
1398            gate.quarantine_reason(
1399                &MemoryAuthor::Operator,
1400                StagedBatchKind::FreshWrite,
1401                &evidence_for(&session)
1402            )
1403            .is_none()
1404        );
1405    }
1406
1407    #[test]
1408    fn reset_boundary_quarantines_evidence_without_content_taint() {
1409        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1410        let session = SessionId::new();
1411        tracker.observe_agent_event("identity:a", &run_started(&session));
1412        assert!(
1413            tracker
1414                .evidence_quarantine_reason(&session.to_string())
1415                .is_none()
1416        );
1417        tracker.mark_reset_boundary(&session.to_string());
1418        let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Observed);
1419        let reason = gate
1420            .quarantine_reason(
1421                &MemoryAuthor::Distiller {
1422                    run_id: "run-1".to_string(),
1423                },
1424                StagedBatchKind::FreshWrite,
1425                &evidence_for(&session),
1426            )
1427            .expect("reset boundary quarantines distillates");
1428        assert!(reason.contains("reset boundary"), "{reason}");
1429    }
1430
1431    #[test]
1432    fn peer_projection_sender_parses_message_and_response_shapes() {
1433        assert_eq!(
1434            peer_projection_sender_identity("Peer message from mob-1/worker/identity:bob:"),
1435            Some("identity:bob")
1436        );
1437        assert_eq!(
1438            peer_projection_sender_identity(
1439                "Peer response from mob-1/worker/identity:bob (to request: req-9)"
1440            ),
1441            Some("identity:bob")
1442        );
1443        // External peers may have plain display names.
1444        assert_eq!(
1445            peer_projection_sender_identity("Peer message from scout:"),
1446            Some("scout")
1447        );
1448        // Peer requests render a raw peer id — unmappable, and honestly so.
1449        assert_eq!(
1450            peer_projection_sender_identity("Peer request from peer_id 018fabc (id: r-1)"),
1451            None
1452        );
1453        assert_eq!(peer_projection_sender_identity("ordinary text"), None);
1454    }
1455
1456    #[test]
1457    fn comms_join_taints_receiver_of_message_from_tainted_sender() {
1458        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1459        // Sender taints its session.
1460        let sender_session = SessionId::new();
1461        tracker.observe_agent_event("identity:bob", &run_started(&sender_session));
1462        tracker.observe_agent_event("identity:bob", &tool_result("web_search"));
1463
1464        // Receiver gets a peer message from the tainted sender: the run's
1465        // injected input carries the canonical projection text.
1466        let receiver_session = SessionId::new();
1467        let delivery = AgentEvent::RunStarted {
1468            session_id: receiver_session.clone(),
1469            input: meerkat_core::types::RunInput::Content {
1470                content: meerkat_core::ContentInput::Text(
1471                    "Peer message from mob-1/worker/identity:bob:\nplease remember X".to_string(),
1472                ),
1473            },
1474        };
1475        tracker.observe_agent_event("identity:alice", &delivery);
1476        let taint = tracker
1477            .identity_taint("identity:alice")
1478            .expect("receiver session taints (peer-laundering close, §10.1)");
1479        assert!(taint.source.contains("identity:bob"), "{}", taint.source);
1480        assert!(
1481            tracker
1482                .session_taint(&receiver_session.to_string())
1483                .is_some()
1484        );
1485
1486        // A message from a clean tracked sender does not taint.
1487        let clean_session = SessionId::new();
1488        tracker.observe_agent_event("identity:carol", &run_started(&clean_session));
1489        let receiver2 = SessionId::new();
1490        let clean_delivery = AgentEvent::RunStarted {
1491            session_id: receiver2.clone(),
1492            input: meerkat_core::types::RunInput::Content {
1493                content: meerkat_core::ContentInput::Text(
1494                    "Peer message from mob-1/worker/identity:carol:\nhello".to_string(),
1495                ),
1496            },
1497        };
1498        tracker.observe_agent_event("identity:dave", &clean_delivery);
1499        assert!(tracker.identity_taint("identity:dave").is_none());
1500    }
1501}