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 {
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
763// ---------------------------------------------------------------------------
764// Observe-stream feed
765// ---------------------------------------------------------------------------
766
767/// A consumer of the per-member agent-event observe stream. The taint
768/// tracker and the Distiller's trigger sink both ride ONE observer loop —
769/// one `subscribe_agent_events` subscription per member, however many
770/// memory stages listen.
771pub 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
781/// §9.1 as-built compaction reset feed: an ALWAYS-ON sink — unconditional,
782/// unlike the distiller's trigger sink — surfacing `CompactionCompleted`
783/// session keys to a callback. The gateway points it at
784/// `AgentMemoryRuntimeInjector::on_session_compacted` so the coordinator's
785/// cross-turn dedup/budget state resets even when no distiller is enabled
786/// (gate finding: budgeted injection without a distiller never reset).
787pub 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/// Guard for the observer task; aborts the task when the last clone drops
809/// (the runtime that owned the sinks is gone).
810#[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
823/// Subscribe the taint observer to every active member's agent-event stream
824/// (the same observe-only `subscribe_agent_events` surface the console
825/// forwarder rides), reconciling membership every second.
826pub 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
833/// Generalized observer: one reconcile loop, one stream per active member,
834/// fanned out to every sink (taint tracker, Distiller triggers, future
835/// stages).
836pub 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                    // Only Active members have a live runtime delta stream;
879                    // subscribing others fails every tick (the console
880                    // forwarder learned this the hard way).
881                    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                            // Usually a short-lived spawn race; retried next
906                            // tick. Warn once per identity, then debug.
907                            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    // Ask 5 (0.7.13): the receiver taints on a sender's SIGNED `Tainted`
977    // declaration, from the typed event — but `None` ("no declaration") and
978    // `Clean` must never taint, and `None` is never coalesced into `Clean`.
979    #[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        // Idempotent boundary: no duplicate event.
1020        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", // sess-1 tainted
1028                "memory.taint.transition", // reset boundary
1029                "memory.taint.transition", // rotated clean
1030            ]
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            // Web builtins are never overridable.
1080            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        // Explicit per-tool trust overrides server-level distrust.
1091        assert_eq!(
1092            config.classify_tool("mcp__evil__probe"),
1093            ToolContentTrust::Trusted
1094        );
1095        // MCP untrusted by default; allowlisted server trusted.
1096        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        // Unknown plain names are trusted in P1 (documented coarseness).
1105        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        // Session-sticky: a later benign event does not clear.
1126        tracker.observe_agent_event("identity:a", &tool_result("shell"));
1127        assert!(tracker.identity_taint("identity:a").is_some());
1128
1129        // Rotation (reset/respawn/fresh spawn mint a new session id) clears.
1130        let fresh = SessionId::new();
1131        tracker.observe_agent_event("identity:a", &run_started(&fresh));
1132        assert!(tracker.identity_taint("identity:a").is_none());
1133        // The old session's fact remains recorded (P2 comms joins read it).
1134        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        // Tool event before any RunStarted (mid-run attach).
1158        tracker.observe_agent_event("identity:a", &tool_result("fetch"));
1159        assert!(tracker.identity_taint("identity:a").is_some());
1160
1161        // The next attributed session inherits the pending taint.
1162        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        // The identity moves on clean...
1177        assert!(tracker.identity_taint("identity:a").is_none());
1178        // ...but the historical fact survives: the comms join and the
1179        // evidence-range gate consult exactly such sessions (P2).
1180        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        // A benign tool makes no declaration.
1204        tracker.observe_agent_event("identity:a", &tool_result("shell"));
1205        assert!(calls.lock().unwrap().is_empty());
1206
1207        // Untrusted ingestion stamps the member Tainted for outbound peer sends.
1208        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        // Rotation to a fresh clean session clears the declaration (None, not
1220        // Clean — an absent declaration, never coalesced into clean).
1221        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        // Reset (the operator escape hatch) is also a fresh-context boundary.
1230        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        // Non-LLM principals are never gated, tainted or not.
1267        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        // llm_writes=quarantined forces quarantine with no taint at all —
1277        // for every first-pass LLM write (Agent, Distiller, and the
1278        // steward's own fresh consolidate/harvest/rank output).
1279        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        // A review verdict IS the review the posture defers to: the posture
1311        // branch must not re-quarantine releases, approved promotions, and
1312        // proposal accepts (they would otherwise never produce an Active
1313        // record). Keyed on the batch kind, not the Steward author.
1314        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        // The review-verdict exemption is posture-branch-only: a review
1329        // batch citing a tainted session still quarantines through the
1330        // evidence-range branch (§10.1).
1331        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        // Identity rotates away: the identity is clean, the session fact
1370        // remains — the distiller's evidence must still quarantine.
1371        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        // Clean evidence does not.
1387        assert!(
1388            gate.quarantine_reason(
1389                &distiller,
1390                StagedBatchKind::FreshWrite,
1391                &evidence_for(&fresh)
1392            )
1393            .is_none()
1394        );
1395        // Non-LLM authors are never evidence-gated.
1396        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        // External peers may have plain display names.
1443        assert_eq!(
1444            peer_projection_sender_identity("Peer message from scout:"),
1445            Some("scout")
1446        );
1447        // Peer requests render a raw peer id — unmappable, and honestly so.
1448        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        // Sender taints its session.
1459        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        // Receiver gets a peer message from the tainted sender: the run's
1464        // injected input carries the canonical projection text.
1465        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        // A message from a clean tracked sender does not taint.
1486        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}