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//! ## Closed at the meerkat 0.8.14 pin (dispatch-ordered trust join)
32//!
33//! - **The first-ingestion race** (ask: taint visibility at tool-dispatch
34//!   time) is CLOSED. Marking no longer depends on the asynchronous
35//!   observe stream alone: [`crate::memory::dispatch_taint`] wraps every
36//!   mob member's agent-facing LLM client (via the pre-build seam every
37//!   member session create passes through), classifies each tool result
38//!   synchronously BEFORE the LLM request that carries it is sent, and
39//!   classifies provider-executed server-tool blocks from the typed
40//!   response before the loop can dispatch any same-response tool call. An
41//!   LLM-authored memory write is always downstream of an LLM call that
42//!   carried the untrusted result, so the tracker is marked strictly
43//!   before that write can reach the store. Mechanism note: 0.8.14's
44//!   `HookPoint::PostToolExecution` fires synchronously with the same
45//!   typed provenance, but the mob member build path has no hook-engine
46//!   carrier (`SessionBuildOptions` cannot ship one), so the join rides
47//!   the sanctioned `agent_llm_client_decorator` seam instead - identical
48//!   ordering guarantee for LLM-authored writes.
49//! - **Name-based classification** is CLOSED for dispatch-time marking:
50//!   the request's tool catalog carries the typed `ToolDef.provenance`
51//!   owner, and [`ContentTrustConfig::classify_tool_with_provenance`]
52//!   attributes MCP tools to their server through it - unqualified MCP
53//!   tool names no longer need `content_trust.untrusted_tools` listing.
54//!   The observe-stream fallback (events carry only the NAME) keeps the
55//!   name-based coarseness; it is belt-and-suspenders behind the
56//!   dispatch-time join, not the primary marker.
57//!
58//! ## Honest gaps that remain (upstream asks, §13)
59//!
60//! - **The mirror race**: after a session rotation, the tracker's view of
61//!   an identity's current session lags until the runtime's delivery hook
62//!   or the new session's first `RunStarted` event updates it, so a write
63//!   in that window can be quarantined against the *old* session's taint.
64//!   This errs conservative (false quarantine, never false trust).
65
66use std::collections::HashMap;
67use std::sync::{Arc, Mutex};
68use std::time::{SystemTime, UNIX_EPOCH};
69
70use meerkat_core::event::AgentEvent;
71use meerkat_core::types::{ServerToolKind, ToolProvenance, ToolSourceKind};
72use serde::{Deserialize, Serialize};
73
74use crate::identity_first::agent_memory::AgentMemoryLlmWrites;
75use crate::memory::records::{EvidenceRef, MemoryAuthor};
76use crate::memory::staged::StagedBatchKind;
77
78/// Builtin web-facing tool names: ALWAYS untrusted for memory purposes, not
79/// overridable by `trusted_tools` (§10.1 "web/fetch always untrusted").
80/// `web_search` is meerkat's builtin client-side search
81/// (`meerkat_core::web_search::WEB_SEARCH_TOOL_NAME`); the others cover the
82/// common fetch spellings across builtin and bundle surfaces.
83const ALWAYS_UNTRUSTED_TOOL_NAMES: &[&str] = &["web_search", "web_fetch", "fetch", "http_request"];
84
85/// Server-qualified MCP tool-name prefix (`mcp__<server>__<tool>`). The only
86/// name shape that lets P1 attribute a tool to an MCP server (see module
87/// docs on classification coarseness).
88const MCP_QUALIFIED_PREFIX: &str = "mcp__";
89
90/// Tainted-session entries are bounded; oldest-tainted evict first. A taint
91/// entry for a dead session is inert (nothing checks it), so eviction only
92/// bounds memory, never correctness for live sessions at sane scales.
93const MAX_TRACKED_TAINTED_SESSIONS: usize = 4096;
94
95/// Which tool sources count as untrusted for memory purposes (§10.1).
96///
97/// Mirrors Codex's `pollutes_memory` posture: web/fetch and provider-native
98/// search are always untrusted; MCP servers are untrusted by default with an
99/// explicit `trusted_mcp_servers` allowlist. Note meerkat's
100/// `ToolAccessPolicy::AllowList` is invocation gating and cannot serve this
101/// role.
102#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
103pub struct ContentTrustConfig {
104    /// MCP servers whose tools do not taint. Joinable in P1 only for
105    /// server-qualified tool names (`mcp__<server>__<tool>`).
106    #[serde(default)]
107    pub trusted_mcp_servers: Vec<String>,
108    /// Explicit tool names that taint the session when their results enter
109    /// context. The escape hatch for unqualified MCP tool names.
110    #[serde(default)]
111    pub untrusted_tools: Vec<String>,
112    /// Explicit tool names that never taint (cannot override the builtin
113    /// web/fetch class or `untrusted_tools`).
114    #[serde(default)]
115    pub trusted_tools: Vec<String>,
116}
117
118/// Classification verdict for one tool name.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum ToolContentTrust {
121    Trusted,
122    Untrusted { source: String },
123}
124
125impl ContentTrustConfig {
126    /// Fail-loud JSON parse for the gateway config block
127    /// `agent_memory.content_trust { ... }`. Unknown fields and wrong types
128    /// are errors, never silently ignored.
129    pub fn from_json_value(value: &serde_json::Value) -> Result<Self, String> {
130        let object = value
131            .as_object()
132            .ok_or_else(|| "content_trust must be an object".to_string())?;
133        let supported = ["trusted_mcp_servers", "untrusted_tools", "trusted_tools"];
134        let unsupported = object
135            .keys()
136            .filter(|key| !supported.contains(&key.as_str()))
137            .map(String::as_str)
138            .collect::<Vec<_>>();
139        if !unsupported.is_empty() {
140            return Err(format!(
141                "unsupported content_trust fields: {}",
142                unsupported.join(", ")
143            ));
144        }
145        let parse_names = |key: &str| -> Result<Vec<String>, String> {
146            match object.get(key) {
147                None => Ok(Vec::new()),
148                Some(value) => {
149                    let entries = value
150                        .as_array()
151                        .ok_or_else(|| format!("content_trust.{key} must be an array"))?;
152                    entries
153                        .iter()
154                        .map(|entry| {
155                            entry
156                                .as_str()
157                                .map(str::trim)
158                                .filter(|name| !name.is_empty())
159                                .map(ToString::to_string)
160                                .ok_or_else(|| {
161                                    format!("content_trust.{key} entries must be non-empty strings")
162                                })
163                        })
164                        .collect()
165                }
166            }
167        };
168        Ok(Self {
169            trusted_mcp_servers: parse_names("trusted_mcp_servers")?,
170            untrusted_tools: parse_names("untrusted_tools")?,
171            trusted_tools: parse_names("trusted_tools")?,
172        })
173    }
174
175    /// Classify a tool by NAME (the only fact the observe-stream event
176    /// surface carries - module docs). Precedence: builtin web/fetch
177    /// (non-overridable) > `untrusted_tools` > `trusted_tools` >
178    /// MCP-qualified names against the server allowlist > trusted.
179    pub fn classify_tool(&self, name: &str) -> ToolContentTrust {
180        self.classify_tool_with_provenance(name, None)
181    }
182
183    /// Classify a tool with the typed [`ToolDef.provenance`] owner when the
184    /// caller has it (the dispatch-time join - module docs). Precedence is
185    /// [`Self::classify_tool`]'s, with the MCP step widened: typed
186    /// `ToolSourceKind::Mcp` provenance attributes the tool to
187    /// `provenance.source_id` regardless of the name shape; absent or
188    /// non-MCP provenance falls back to the `mcp__<server>__<tool>` name
189    /// join. Explicit `trusted_tools` entries still override server-level
190    /// distrust, exactly as on the name-only path.
191    ///
192    /// [`ToolDef.provenance`]: meerkat_core::ToolDef
193    pub fn classify_tool_with_provenance(
194        &self,
195        name: &str,
196        provenance: Option<&ToolProvenance>,
197    ) -> ToolContentTrust {
198        if ALWAYS_UNTRUSTED_TOOL_NAMES.contains(&name) {
199            return ToolContentTrust::Untrusted {
200                source: format!("web tool '{name}'"),
201            };
202        }
203        if self.untrusted_tools.iter().any(|tool| tool == name) {
204            return ToolContentTrust::Untrusted {
205                source: format!("configured untrusted tool '{name}'"),
206            };
207        }
208        if self.trusted_tools.iter().any(|tool| tool == name) {
209            return ToolContentTrust::Trusted;
210        }
211        if let Some(provenance) = provenance
212            && provenance.kind == ToolSourceKind::Mcp
213        {
214            return self.classify_mcp_server(provenance.source_id.as_str(), name);
215        }
216        if let Some(rest) = name.strip_prefix(MCP_QUALIFIED_PREFIX) {
217            let server = rest.split("__").next().unwrap_or(rest);
218            return self.classify_mcp_server(server, name);
219        }
220        ToolContentTrust::Trusted
221    }
222
223    fn classify_mcp_server(&self, server: &str, name: &str) -> ToolContentTrust {
224        if self
225            .trusted_mcp_servers
226            .iter()
227            .any(|trusted| trusted == server)
228        {
229            return ToolContentTrust::Trusted;
230        }
231        ToolContentTrust::Untrusted {
232            source: format!("MCP server '{server}' (tool '{name}')"),
233        }
234    }
235}
236
237/// Why and when a session became tainted.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct TaintState {
240    pub tainted_at_ms: u64,
241    pub source: String,
242}
243
244#[derive(Default)]
245struct TaintInner {
246    /// identity → the session key the tracker currently attributes that
247    /// identity's activity to. Fed authoritatively by the identity runtime's
248    /// delivery/reset hooks and, as fallback, by observed `RunStarted`
249    /// events (peer-comms-driven runs never pass through the runtime hooks).
250    current_session: HashMap<String, String>,
251    /// session key → taint fact. Session-sticky by construction, and
252    /// retained after rotation/clear: the fact is historical ("this session
253    /// ingested untrusted content"), and the P2 comms join and
254    /// evidence-range gate read it for sessions that are no longer current.
255    tainted: HashMap<String, TaintState>,
256    /// Untrusted ingestion observed before the observer learned the
257    /// identity's session (mid-run attach). Transferred to the next learned
258    /// session — conservative direction (see module docs).
259    pending_identity_taint: HashMap<String, TaintState>,
260    /// session key → reset-boundary mark (§8.4): distillates citing this
261    /// session quarantine pending steward review. Bounded like `tainted`.
262    reset_boundaries: HashMap<String, u64>,
263}
264
265/// Host callback that stamps a member's OUTBOUND content-taint declaration
266/// (§10.1 ask 5, outbound half). Invoked with the member's public identity and
267/// the taint to declare — `Some(Tainted)` when the member's session ingests
268/// untrusted content, `None` when it rotates back to a clean session — so the
269/// member's peer sends carry the sender's signed declaration and receivers can
270/// propagate taint cross-process. The callback is fire-and-forget (the real
271/// declare is async on the mob runtime); it must not block.
272pub type OutboundTaintDeclarer =
273    Arc<dyn Fn(&str, Option<meerkat_core::comms::SenderContentTaint>) + Send + Sync>;
274
275/// Session-sticky taint tracker (§10.1, coarse P1). Cheap to clone; clones
276/// share state.
277#[derive(Clone, Default)]
278pub struct SessionTaintTracker {
279    config: Arc<ContentTrustConfig>,
280    inner: Arc<Mutex<TaintInner>>,
281    /// §9.3 timeline sink for taint transitions; shared across clones.
282    event_sink: Arc<Mutex<Option<Arc<dyn crate::memory::events::MemoryEventSink>>>>,
283    /// §10.1 ask 5 outbound half: declares a member's outbound content-taint
284    /// on the mob runtime so its peer sends carry the flag. Shared across
285    /// clones; `None` until the gateway wires the mob handle.
286    outbound_declarer: Arc<Mutex<Option<OutboundTaintDeclarer>>>,
287}
288
289impl SessionTaintTracker {
290    pub fn new(config: ContentTrustConfig) -> Self {
291        Self {
292            config: Arc::new(config),
293            inner: Arc::new(Mutex::new(TaintInner::default())),
294            event_sink: Arc::new(Mutex::new(None)),
295            outbound_declarer: Arc::new(Mutex::new(None)),
296        }
297    }
298
299    /// Wire the §10.1 outbound-taint declarer (bound to the mob handle at
300    /// gateway bootstrap). Shared across clones.
301    pub fn set_outbound_taint_declarer(&self, declarer: OutboundTaintDeclarer) {
302        *self
303            .outbound_declarer
304            .lock()
305            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(declarer);
306    }
307
308    /// Declare a member's outbound content-taint, if a declarer is wired.
309    /// Called on taint transitions (set/clear); must be invoked WITHOUT the
310    /// `inner` lock held (the callback may take time to hand off).
311    fn declare_outbound(
312        &self,
313        identity: &str,
314        taint: Option<meerkat_core::comms::SenderContentTaint>,
315    ) {
316        if let Some(declarer) = self
317            .outbound_declarer
318            .lock()
319            .unwrap_or_else(std::sync::PoisonError::into_inner)
320            .as_ref()
321        {
322            declarer(identity, taint);
323        }
324    }
325
326    /// Wire the §9.3 timeline sink so taint transitions surface on the
327    /// console alongside the tracing warns. Shared across clones.
328    pub fn set_event_sink(&self, sink: Arc<dyn crate::memory::events::MemoryEventSink>) {
329        *self
330            .event_sink
331            .lock()
332            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(sink);
333    }
334
335    fn emit_event(&self, event: crate::memory::events::MemoryTimelineEvent) {
336        if let Some(sink) = self
337            .event_sink
338            .lock()
339            .unwrap_or_else(std::sync::PoisonError::into_inner)
340            .as_ref()
341        {
342            sink.emit(event);
343        }
344    }
345
346    /// Observe one agent event for `identity` (the observe-only agent-event
347    /// stream is per-member, so attribution is the subscription's).
348    pub fn observe_agent_event(&self, identity: &str, event: &AgentEvent) {
349        match event {
350            AgentEvent::RunStarted { session_id, input } => {
351                let session_key = session_id.to_string();
352                self.note_current_session(identity, &session_key);
353                // §10.1 comms taint join (legacy, belt-and-braces): before
354                // meerkat 0.7.13 the only inbound signal was projection text.
355                // It stays as a fallback for same-process senders whose taint
356                // this host tracks but who make no envelope declaration.
357                if let Some(text) = input.prompt_text() {
358                    self.observe_inbound_peer_content(identity, &session_key, &text);
359                }
360            }
361            // §10.1 ask 5 (0.7.13): consume the sender's SIGNED content-taint
362            // declaration from the typed peer-ingestion event — canonical peer
363            // identity, emitted synchronously as the block commits — instead of
364            // parsing rendered projection text. Only an affirmative `Tainted`
365            // taints the receiving session; `None` ("no declaration") and
366            // `Clean` do not, and `None` is never coalesced into `Clean`.
367            AgentEvent::PeerContentIngested {
368                peer, sender_taint, ..
369            } => {
370                if *sender_taint == Some(meerkat_core::comms::SenderContentTaint::Tainted) {
371                    let sender = peer
372                        .as_ref()
373                        .and_then(|peer| peer.display_name.clone())
374                        .unwrap_or_else(|| "peer".to_string());
375                    self.mark_identity_tainted(
376                        identity,
377                        format!("peer content declared tainted by sender '{sender}'"),
378                    );
379                }
380            }
381            // Taint on result *ingestion*: these are the events whose content
382            // enters the conversation context. Errors taint too — an error
383            // body is still attacker-influenced text in context.
384            AgentEvent::ToolResultReceived { name, .. }
385            | AgentEvent::ToolExecutionCompleted { name, .. } => {
386                if let ToolContentTrust::Untrusted { source } = self.config.classify_tool(name) {
387                    self.mark_identity_tainted(identity, source);
388                }
389            }
390            // Provider-executed server tools (web search / grounding /
391            // provider-native): typed, and always untrusted (§10.1).
392            AgentEvent::ServerToolContent { kind, .. } => {
393                self.mark_identity_tainted(
394                    identity,
395                    format!("provider server tool '{}'", kind.provider_name()),
396                );
397            }
398            _ => {}
399        }
400    }
401
402    /// Dispatch-ordered ingestion feed (module docs, "closed at 0.8.14"):
403    /// classify one tool result with the typed provenance from the request's
404    /// tool catalog and mark the identity synchronously - strictly before
405    /// the LLM call that carries the result, so no same-turn LLM memory
406    /// write can beat the mark to the store. Marking is idempotent with the
407    /// observe-stream fallback, which stays wired behind this.
408    pub fn observe_dispatched_tool_result(
409        &self,
410        identity: &str,
411        name: &str,
412        provenance: Option<&ToolProvenance>,
413    ) {
414        if let ToolContentTrust::Untrusted { source } =
415            self.config.classify_tool_with_provenance(name, provenance)
416        {
417            self.mark_identity_tainted(identity, source);
418        }
419    }
420
421    /// Dispatch-ordered feed for provider-executed server tools (web search /
422    /// grounding): typed, and always untrusted (§10.1). Called from the
423    /// LLM-boundary join with the response's typed `ServerToolContent`
424    /// blocks, before the loop can dispatch any same-response tool call.
425    pub fn observe_dispatched_server_tool(&self, identity: &str, kind: &ServerToolKind) {
426        self.mark_identity_tainted(
427            identity,
428            format!("provider server tool '{}'", kind.provider_name()),
429        );
430    }
431
432    /// Authoritative current-session hint from the identity runtime's
433    /// delivery path. Keeps the tracker's attribution ahead of the (async)
434    /// observe stream on the paths MobKit controls.
435    pub fn note_current_session(&self, identity: &str, session_key: &str) {
436        let mut inner = self
437            .inner
438            .lock()
439            .unwrap_or_else(std::sync::PoisonError::into_inner);
440        let pending = inner.pending_identity_taint.remove(identity);
441        let previous = inner
442            .current_session
443            .insert(identity.to_string(), session_key.to_string());
444        let rotated_away_from_tainted = previous.as_deref() != Some(session_key)
445            && previous.is_some_and(|prior| inner.tainted.contains_key(&prior));
446        if rotated_away_from_tainted {
447            tracing::warn!(
448                identity,
449                session_key,
450                "agent memory taint: session rotated away from a tainted session; \
451                 new session starts clean"
452            );
453            self.emit_event(
454                crate::memory::events::MemoryTimelineEvent::TaintTransition {
455                    identity: Some(identity.to_string()),
456                    session_key: session_key.to_string(),
457                    kind: "rotated_clean".to_string(),
458                    source: "session rotation away from tainted session".to_string(),
459                },
460            );
461        }
462        // A held pre-attribution taint re-lands on the new session — it stays
463        // tainted, so do not clear the outbound declaration below.
464        let pending_reapplied = pending.is_some();
465        if let Some(state) = pending {
466            self.insert_taint(&mut inner, session_key.to_string(), state);
467        }
468        // §10.1 outbound half: on a clean rotation with no re-landed taint, the
469        // identity's live session is clean again — clear its outbound stamp.
470        if rotated_away_from_tainted && !pending_reapplied {
471            drop(inner);
472            self.declare_outbound(identity, None);
473        }
474    }
475
476    /// Explicit clear for the reset path (`reset()` is the operator's escape
477    /// hatch from a poisoned session). Rotation already clears implicitly;
478    /// this also drops any pending pre-attribution taint. The outgoing
479    /// session's *fact* is deliberately retained (P2): "that session was
480    /// tainted" stays true after the identity moves on, and the comms join
481    /// and the Distiller's evidence gate consult it for exactly such
482    /// sessions.
483    pub fn clear_identity(&self, identity: &str) {
484        let mut inner = self
485            .inner
486            .lock()
487            .unwrap_or_else(std::sync::PoisonError::into_inner);
488        inner.pending_identity_taint.remove(identity);
489        inner.current_session.remove(identity);
490        // §10.1 outbound half: reset is a fresh-context boundary — the identity
491        // no longer speaks for a tainted session, so clear its outbound stamp.
492        drop(inner);
493        self.declare_outbound(identity, None);
494    }
495
496    /// Mark a `reset()` boundary on the outgoing session (§8.4): every
497    /// LLM-authored write whose evidence cites this session quarantines
498    /// pending steward review, regardless of content taint. Idempotent.
499    pub fn mark_reset_boundary(&self, session_key: &str) {
500        let mut inner = self
501            .inner
502            .lock()
503            .unwrap_or_else(std::sync::PoisonError::into_inner);
504        if inner
505            .reset_boundaries
506            .insert(session_key.to_string(), now_ms())
507            .is_none()
508        {
509            tracing::warn!(
510                session_key,
511                "agent memory taint: reset boundary marked; distillates over this \
512                 session will land quarantined pending steward review (§8.4)"
513            );
514            self.emit_event(
515                crate::memory::events::MemoryTimelineEvent::TaintTransition {
516                    identity: None,
517                    session_key: session_key.to_string(),
518                    kind: "reset_boundary".to_string(),
519                    source: "reset() boundary (§8.4)".to_string(),
520                },
521            );
522        }
523        if inner.reset_boundaries.len() > MAX_TRACKED_TAINTED_SESSIONS
524            && let Some(oldest) = inner
525                .reset_boundaries
526                .iter()
527                .min_by_key(|(_, at_ms)| **at_ms)
528                .map(|(key, _)| key.clone())
529        {
530            inner.reset_boundaries.remove(&oldest);
531        }
532    }
533
534    /// §10.1/§8.4 evidence gate query: why a write citing `session_key` as
535    /// evidence must quarantine, if it must. Coarse by design: the tracker
536    /// holds session-sticky facts, so a tainted session taints every
537    /// evidence range within it.
538    pub fn evidence_quarantine_reason(&self, session_key: &str) -> Option<String> {
539        let inner = self
540            .inner
541            .lock()
542            .unwrap_or_else(std::sync::PoisonError::into_inner);
543        if let Some(state) = inner.tainted.get(session_key) {
544            return Some(format!(
545                "evidence session tainted by {} (session-tainted ⇒ range-tainted)",
546                state.source
547            ));
548        }
549        if inner.reset_boundaries.contains_key(session_key) {
550            return Some(
551                "evidence session closed at a reset boundary; distillates quarantine \
552                 pending steward review (§8.4)"
553                    .to_string(),
554            );
555        }
556        None
557    }
558
559    /// Taint fact for an explicit session key.
560    pub fn session_taint(&self, session_key: &str) -> Option<TaintState> {
561        let inner = self
562            .inner
563            .lock()
564            .unwrap_or_else(std::sync::PoisonError::into_inner);
565        inner.tainted.get(session_key).cloned()
566    }
567
568    /// Taint fact for the identity's currently-attributed session (the write
569    /// gate's query: LLM-authored writes carry identity, not session).
570    pub fn identity_taint(&self, identity: &str) -> Option<TaintState> {
571        let inner = self
572            .inner
573            .lock()
574            .unwrap_or_else(std::sync::PoisonError::into_inner);
575        if let Some(state) = inner.pending_identity_taint.get(identity) {
576            return Some(state.clone());
577        }
578        inner
579            .current_session
580            .get(identity)
581            .and_then(|session| inner.tainted.get(session))
582            .cloned()
583    }
584
585    /// §10.1 comms taint join, over what the observe surface actually
586    /// carries. Meerkat 0.7.9 has **no typed inbound peer-message event**:
587    /// a peer delivery lands in the receiver's session as injected prompt
588    /// text rendered by `format_peer_message_projection` /
589    /// `format_peer_response_projection` (meerkat-core `interaction.rs:126,
590    /// :239`), where the sender is the resolved trusted-peer name — for mob
591    /// members the `MemberCommsName` `{mob_id}/{role}/{agent_identity}`
592    /// (meerkat-core `connection.rs:368`). This join parses that sender out
593    /// and taints the receiving session when the sender's tracked session
594    /// is tainted.
595    ///
596    /// Honest limits, filed against upstream ask 5 (envelope-level taint
597    /// flags):
598    /// - **"tainted at send time" is approximated at delivery-observe
599    ///   time.** If the sender rotated to a clean session between send and
600    ///   delivery, the join misses; if the sender got tainted after the
601    ///   send, the join over-taints (conservative direction).
602    /// - **Peer *requests* render a raw cryptographic `peer_id`**, not a
603    ///   comms name, so their senders are unmappable host-side — unknowable
604    ///   without the upstream envelope fact.
605    /// - **Cross-process senders** are not in this tracker at all;
606    ///   sender-session taint state is unknowable for them.
607    /// - A user message quoting the projection prefix can false-positive —
608    ///   conservative (false quarantine, never false trust).
609    fn observe_inbound_peer_content(&self, identity: &str, session_key: &str, text: &str) {
610        for line in text.lines() {
611            let Some(sender_identity) = peer_projection_sender_identity(line) else {
612                continue;
613            };
614            if sender_identity == identity {
615                continue;
616            }
617            let source = {
618                let inner = self
619                    .inner
620                    .lock()
621                    .unwrap_or_else(std::sync::PoisonError::into_inner);
622                inner
623                    .pending_identity_taint
624                    .get(sender_identity)
625                    .or_else(|| {
626                        inner
627                            .current_session
628                            .get(sender_identity)
629                            .and_then(|session| inner.tainted.get(session))
630                    })
631                    .map(|state| state.source.clone())
632            };
633            if let Some(source) = source {
634                let state = TaintState {
635                    tainted_at_ms: now_ms(),
636                    source: format!(
637                        "peer message from tainted sender '{sender_identity}' \
638                         (sender session tainted by {source})"
639                    ),
640                };
641                let mut inner = self
642                    .inner
643                    .lock()
644                    .unwrap_or_else(std::sync::PoisonError::into_inner);
645                self.insert_taint(&mut inner, session_key.to_string(), state);
646            }
647        }
648    }
649
650    fn mark_identity_tainted(&self, identity: &str, source: String) {
651        let state = TaintState {
652            tainted_at_ms: now_ms(),
653            source,
654        };
655        let mut inner = self
656            .inner
657            .lock()
658            .unwrap_or_else(std::sync::PoisonError::into_inner);
659        match inner.current_session.get(identity).cloned() {
660            Some(session) => self.insert_taint(&mut inner, session, state),
661            None => {
662                // Mid-run attach: session unknown until the next RunStarted /
663                // delivery hook. Hold identity-sticky (module docs).
664                match inner.pending_identity_taint.entry(identity.to_string()) {
665                    std::collections::hash_map::Entry::Vacant(slot) => {
666                        tracing::warn!(
667                            identity,
668                            source = %state.source,
669                            "agent memory taint: untrusted ingestion observed before session \
670                             attribution; holding identity-sticky taint"
671                        );
672                        slot.insert(state);
673                    }
674                    std::collections::hash_map::Entry::Occupied(mut slot) => {
675                        slot.insert(state);
676                    }
677                }
678            }
679        }
680        // §10.1 outbound half: the identity has ingested untrusted content, so
681        // stamp its peer sends Tainted (whether the taint landed on the current
682        // session or is held pending). Declare OUTSIDE the `inner` lock.
683        drop(inner);
684        self.declare_outbound(
685            identity,
686            Some(meerkat_core::comms::SenderContentTaint::Tainted),
687        );
688    }
689
690    fn insert_taint(&self, inner: &mut TaintInner, session: String, state: TaintState) {
691        match inner.tainted.entry(session) {
692            std::collections::hash_map::Entry::Occupied(_) => return,
693            std::collections::hash_map::Entry::Vacant(slot) => {
694                tracing::warn!(
695                    session_key = %slot.key(),
696                    source = %state.source,
697                    "agent memory taint: session ingested untrusted content; LLM-authored \
698                     memory writes from this session will quarantine until a fresh-context \
699                     boundary (reset/respawn/fresh spawn)"
700                );
701                self.emit_event(
702                    crate::memory::events::MemoryTimelineEvent::TaintTransition {
703                        identity: None,
704                        session_key: slot.key().clone(),
705                        kind: "tainted".to_string(),
706                        source: state.source.clone(),
707                    },
708                );
709                slot.insert(state);
710            }
711        }
712        if inner.tainted.len() > MAX_TRACKED_TAINTED_SESSIONS
713            && let Some(oldest) = inner
714                .tainted
715                .iter()
716                .min_by_key(|(_, state)| state.tainted_at_ms)
717                .map(|(key, _)| key.clone())
718        {
719            inner.tainted.remove(&oldest);
720        }
721    }
722}
723
724/// Store-seam write gate (§10.1 posture): consulted by the bundled store for
725/// every LLM-authored create/supersede so the quarantine decision holds for
726/// ALL callers — the Recorder tool, staged batches, and any future stage —
727/// not just the tool handler.
728pub trait LlmWriteGate: Send + Sync {
729    /// `Some(reason)` when this LLM-authored write must land
730    /// `RecordStatus::Quarantined`. Non-LLM principals are never gated.
731    /// `kind` is the batch's semantic kind (§10.1): review verdicts are
732    /// the review the quarantine posture defers to, fresh writes are not.
733    /// `evidence` is the union of `EvidenceRef`s the write cites (P2
734    /// evidence-range taint, §10.1); empty for writes that cite nothing.
735    fn quarantine_reason(
736        &self,
737        author: &MemoryAuthor,
738        kind: StagedBatchKind,
739        evidence: &[EvidenceRef],
740    ) -> Option<String>;
741}
742
743/// The taint/posture gate: `llm_writes = "quarantined"` forces every
744/// first-pass LLM-authored write (Agent, Distiller, and the steward's own
745/// consolidate/harvest/rank output) into quarantine regardless of taint;
746/// agent-authored writes quarantine when the author's session is tainted;
747/// and ANY LLM-authored write (Steward and Distiller included) quarantines
748/// when its evidence cites a tainted session or a reset boundary
749/// (§8.4/§10.1 — coarse: session-tainted ⇒ range-tainted).
750///
751/// Review-verdict batches (`StagedBatchKind::ReviewVerdict`) are exempt
752/// from the *posture* branch only: a review verdict IS the review the
753/// posture defers to (§10.1 "quarantined until steward/operator review") —
754/// quarantine releases, gating-approved promotions, and proposal accepts
755/// commit as review verdicts, and re-quarantining them would make review
756/// unable to ever produce an Active record under the conservative posture.
757/// The exemption is keyed on the batch's semantic kind, NOT on the Steward
758/// author: all dream groups carry `MemoryAuthor::Steward`, but fresh
759/// steward LLM output (consolidate creates, harvest copies, rank batches)
760/// is first-pass content and respects the posture knob. The evidence-taint
761/// branch still applies to every batch, so a consolidation citing a
762/// tainted session quarantines like any other write.
763pub struct TaintLlmWriteGate {
764    tracker: Option<SessionTaintTracker>,
765    llm_writes: AgentMemoryLlmWrites,
766}
767
768impl TaintLlmWriteGate {
769    pub fn new(tracker: Option<SessionTaintTracker>, llm_writes: AgentMemoryLlmWrites) -> Self {
770        Self {
771            tracker,
772            llm_writes,
773        }
774    }
775}
776
777impl LlmWriteGate for TaintLlmWriteGate {
778    fn quarantine_reason(
779        &self,
780        author: &MemoryAuthor,
781        kind: StagedBatchKind,
782        evidence: &[EvidenceRef],
783    ) -> Option<String> {
784        if !author.is_llm() {
785            return None;
786        }
787        if self.llm_writes == AgentMemoryLlmWrites::Quarantined
788            && kind != StagedBatchKind::ReviewVerdict
789        {
790            return Some("llm_writes=quarantined policy".to_string());
791        }
792        let tracker = self.tracker.as_ref()?;
793        if let MemoryAuthor::Agent { identity } = author
794            && let Some(state) = tracker.identity_taint(identity)
795        {
796            return Some(format!("session tainted by {}", state.source));
797        }
798        for evidence_ref in evidence {
799            if let Some(reason) = tracker.evidence_quarantine_reason(&evidence_ref.session_id) {
800                return Some(reason);
801            }
802        }
803        None
804    }
805}
806
807/// Sender-identity extraction from one line of injected peer-projection
808/// text. Pinned to meerkat 0.7.9's canonical projections:
809/// `format_peer_message_projection` → `"Peer message from {name}:"` and
810/// `format_peer_response_projection` → `"Peer response from {name} (to
811/// request: ...)"`, where `{name}` for a mob member is
812/// `{mob_id}/{role}/{agent_identity}`. Returns the trailing path segment
813/// (the agent identity). Peer *requests* carry a raw peer id and return
814/// `None` (module docs on the honest gaps).
815fn peer_projection_sender_identity(line: &str) -> Option<&str> {
816    let name = if let Some(rest) = line.strip_prefix("Peer message from ") {
817        // Canonical shape ends the line with ':' (body follows on the next
818        // line). Names may themselves contain ':' (agent identities do), so
819        // strip the trailing delimiter rather than splitting at the first.
820        match rest.split_once(": ") {
821            Some((name, _)) => name.trim(),
822            None => rest.strip_suffix(':').unwrap_or(rest).trim(),
823        }
824    } else {
825        let rest = line.strip_prefix("Peer response from ")?;
826        rest.split(" (to request:").next()?.trim()
827    };
828    if name.is_empty() {
829        return None;
830    }
831    Some(name.rsplit('/').next().unwrap_or(name))
832}
833
834// ---------------------------------------------------------------------------
835// Observe-stream feed
836// ---------------------------------------------------------------------------
837
838/// A consumer of the per-member agent-event observe stream. The taint
839/// tracker and the Distiller's trigger sink both ride ONE observer loop —
840/// one `subscribe_agent_events` subscription per member, however many
841/// memory stages listen.
842pub trait MemberAgentEventSink: Send + Sync {
843    fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>);
844}
845
846impl MemberAgentEventSink for SessionTaintTracker {
847    fn observe(&self, identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
848        self.observe_agent_event(identity, &envelope.payload);
849    }
850}
851
852/// §9.1 as-built compaction reset feed: an ALWAYS-ON sink — unconditional,
853/// unlike the distiller's trigger sink — surfacing `CompactionCompleted`
854/// session keys to a callback. The gateway points it at
855/// `AgentMemoryRuntimeInjector::on_session_compacted` so the coordinator's
856/// cross-turn dedup/budget state resets even when no distiller is enabled
857/// (gate finding: budgeted injection without a distiller never reset).
858pub struct CompactionResetSink {
859    on_compacted: Arc<dyn Fn(&str) + Send + Sync>,
860}
861
862impl CompactionResetSink {
863    pub fn new(on_compacted: Arc<dyn Fn(&str) + Send + Sync>) -> Self {
864        Self { on_compacted }
865    }
866}
867
868impl MemberAgentEventSink for CompactionResetSink {
869    fn observe(&self, _identity: &str, envelope: &meerkat_core::event::EventEnvelope<AgentEvent>) {
870        if matches!(envelope.payload, AgentEvent::CompactionCompleted { .. })
871            && let meerkat_core::event::EventSourceIdentity::Session { session_id } =
872                &envelope.source
873        {
874            (self.on_compacted)(&session_id.to_string());
875        }
876    }
877}
878
879/// Guard for the observer task; aborts the task when the last clone drops
880/// (the runtime that owned the sinks is gone).
881#[derive(Clone)]
882pub struct TaintObserverGuard {
883    _abort: Arc<AbortOnDrop>,
884}
885
886struct AbortOnDrop(std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>);
887
888impl Drop for AbortOnDrop {
889    fn drop(&mut self) {
890        if let Some(task) = self
891            .0
892            .get_mut()
893            .unwrap_or_else(std::sync::PoisonError::into_inner)
894            .take()
895        {
896            task.abort();
897        }
898    }
899}
900
901impl TaintObserverGuard {
902    /// Abort and join the observer rather than merely relying on drop. The
903    /// runtime uses this at shutdown so a failed builder cannot leave a ghost
904    /// subscriber retaining the mob handle and memory sinks.
905    pub async fn abort_and_join(self) {
906        let task = self
907            ._abort
908            .0
909            .lock()
910            .unwrap_or_else(std::sync::PoisonError::into_inner)
911            .take();
912        if let Some(task) = task {
913            task.abort();
914            let _ = task.await;
915        }
916    }
917}
918
919/// Subscribe the taint observer to every active member's agent-event stream
920/// (the same observe-only `subscribe_agent_events` surface the console
921/// forwarder rides), reconciling membership every second.
922pub fn spawn_taint_observer(
923    handle: meerkat_mob::MobHandle,
924    tracker: SessionTaintTracker,
925) -> TaintObserverGuard {
926    spawn_member_event_observer(handle, vec![Arc::new(tracker)])
927}
928
929/// Generalized observer: one reconcile loop, one stream per active member,
930/// fanned out to every sink (taint tracker, Distiller triggers, future
931/// stages).
932pub fn spawn_member_event_observer(
933    handle: meerkat_mob::MobHandle,
934    sinks: Vec<Arc<dyn MemberAgentEventSink>>,
935) -> TaintObserverGuard {
936    let task = tokio::spawn(run_member_event_observer(handle, sinks));
937    TaintObserverGuard {
938        _abort: Arc::new(AbortOnDrop(std::sync::Mutex::new(Some(task)))),
939    }
940}
941
942async fn run_member_event_observer(
943    handle: meerkat_mob::MobHandle,
944    sinks: Vec<Arc<dyn MemberAgentEventSink>>,
945) {
946    use futures::StreamExt;
947    use futures::stream::SelectAll;
948
949    enum Observed {
950        Event(String, Box<meerkat_core::event::EventEnvelope<AgentEvent>>),
951        Closed(String),
952    }
953
954    let mut streams: SelectAll<futures::stream::BoxStream<'static, Observed>> = SelectAll::new();
955    let mut subscribed: std::collections::HashSet<String> = std::collections::HashSet::new();
956    let mut warned: std::collections::HashSet<String> = std::collections::HashSet::new();
957    let mut reconcile = tokio::time::interval(std::time::Duration::from_secs(1));
958    reconcile.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
959
960    loop {
961        tokio::select! {
962            Some(observed) = streams.next() => match observed {
963                Observed::Event(identity, envelope) => {
964                    for sink in &sinks {
965                        sink.observe(&identity, &envelope);
966                    }
967                }
968                Observed::Closed(identity) => {
969                    subscribed.remove(&identity);
970                }
971            },
972            _ = reconcile.tick() => {
973                for entry in handle.list_members_including_retiring().await {
974                    // Only Active members have a live runtime delta stream;
975                    // subscribing others fails every tick (the console
976                    // forwarder learned this the hard way).
977                    if entry.status != meerkat_mob::MobMemberStatus::Active {
978                        continue;
979                    }
980                    let identity = entry.agent_identity.to_string();
981                    if subscribed.contains(&identity) {
982                        continue;
983                    }
984                    // Sinks receive the LOGICAL identity (task #53): the
985                    // roster id is the comms-safe encoding of the member's
986                    // alias, and identity-first internal members roster
987                    // under their generated runtime alias
988                    // (rt:{identity}:{generation}) - both decode/strip to
989                    // the durable identity the memory scopes, the write
990                    // gate, and the SDK surface key on. Keying sinks by the
991                    // roster id is what split distiller scopes per
992                    // incarnation (HomeCore activation smoke). Subscription
993                    // bookkeeping (`subscribed`/`Closed`) stays keyed by the
994                    // roster id - that is the handle's namespace.
995                    let sink_identity =
996                        crate::member_comms_id::logical_memory_identity(&identity);
997                    match handle.subscribe_agent_events(&entry.agent_identity).await {
998                        Ok(stream) => {
999                            warned.remove(&identity);
1000                            subscribed.insert(identity.clone());
1001                            let close_key = identity.clone();
1002                            streams.push(
1003                                stream
1004                                    .map(move |envelope| {
1005                                        Observed::Event(
1006                                            sink_identity.clone(),
1007                                            Box::new(envelope),
1008                                        )
1009                                    })
1010                                    .chain(futures::stream::once(async move {
1011                                        Observed::Closed(close_key)
1012                                    }))
1013                                    .boxed(),
1014                            );
1015                        }
1016                        Err(error) => {
1017                            // Usually a short-lived spawn race; retried next
1018                            // tick. Warn once per identity, then debug.
1019                            if warned.insert(identity.clone()) {
1020                                tracing::warn!(
1021                                    identity = %identity,
1022                                    error = %error,
1023                                    "agent memory taint observer: failed to subscribe; will retry"
1024                                );
1025                            } else {
1026                                tracing::debug!(
1027                                    identity = %identity,
1028                                    error = %error,
1029                                    "agent memory taint observer: subscribe still failing"
1030                                );
1031                            }
1032                        }
1033                    }
1034                }
1035            }
1036        }
1037    }
1038}
1039
1040fn now_ms() -> u64 {
1041    SystemTime::now()
1042        .duration_since(UNIX_EPOCH)
1043        .map(|duration| duration.as_millis() as u64)
1044        .unwrap_or(0)
1045}
1046
1047#[cfg(test)]
1048#[allow(
1049    clippy::expect_used,
1050    clippy::panic,
1051    clippy::redundant_clone,
1052    clippy::unwrap_used
1053)]
1054mod tests {
1055    use super::*;
1056    use meerkat_core::types::{ContentBlock, ServerToolKind, SessionId};
1057    use serde_json::json;
1058
1059    fn run_started(session: &SessionId) -> AgentEvent {
1060        AgentEvent::RunStarted {
1061            session_id: session.clone(),
1062            input: meerkat_core::types::RunInput::Content {
1063                content: meerkat_core::ContentInput::Text("hi".to_string()),
1064            },
1065        }
1066    }
1067
1068    fn tool_result(name: &str) -> AgentEvent {
1069        AgentEvent::ToolResultReceived {
1070            id: "tool-1".to_string(),
1071            name: name.to_string(),
1072            content: vec![ContentBlock::Text {
1073                text: "ok".to_string(),
1074            }],
1075            is_error: false,
1076        }
1077    }
1078
1079    fn peer_ingested(taint: Option<meerkat_core::comms::SenderContentTaint>) -> AgentEvent {
1080        AgentEvent::PeerContentIngested {
1081            kind: meerkat_core::types::CommsNoticeKind::Message,
1082            peer: None,
1083            request_id: None,
1084            sender_taint: taint,
1085        }
1086    }
1087
1088    // Ask 5 (0.7.13): the receiver taints on a sender's SIGNED `Tainted`
1089    // declaration, from the typed event — but `None` ("no declaration") and
1090    // `Clean` must never taint, and `None` is never coalesced into `Clean`.
1091    #[test]
1092    fn declared_peer_taint_taints_receiver_but_none_or_clean_does_not() {
1093        use meerkat_core::comms::SenderContentTaint;
1094        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1095        tracker.note_current_session("identity:b", "sess-b");
1096
1097        tracker.observe_agent_event("identity:b", &peer_ingested(None));
1098        assert!(
1099            tracker.session_taint("sess-b").is_none(),
1100            "no declaration must not taint"
1101        );
1102
1103        tracker.observe_agent_event(
1104            "identity:b",
1105            &peer_ingested(Some(SenderContentTaint::Clean)),
1106        );
1107        assert!(
1108            tracker.session_taint("sess-b").is_none(),
1109            "an affirmative Clean declaration must not taint"
1110        );
1111
1112        tracker.observe_agent_event(
1113            "identity:b",
1114            &peer_ingested(Some(SenderContentTaint::Tainted)),
1115        );
1116        assert!(
1117            tracker.session_taint("sess-b").is_some(),
1118            "a declared-tainted peer delivery taints the receiving session"
1119        );
1120    }
1121
1122    #[test]
1123    fn taint_transitions_emit_timeline_events_when_sink_wired() {
1124        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1125        let sink = std::sync::Arc::new(crate::memory::events::CollectingEventSink::new());
1126        tracker.set_event_sink(sink.clone());
1127
1128        tracker.note_current_session("identity:a", "sess-1");
1129        tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1130        tracker.mark_reset_boundary("sess-1");
1131        // Idempotent boundary: no duplicate event.
1132        tracker.mark_reset_boundary("sess-1");
1133        tracker.note_current_session("identity:a", "sess-2");
1134
1135        let types = sink.types();
1136        assert_eq!(
1137            types,
1138            vec![
1139                "memory.taint.transition", // sess-1 tainted
1140                "memory.taint.transition", // reset boundary
1141                "memory.taint.transition", // rotated clean
1142            ]
1143        );
1144        let events = sink.events.lock().unwrap();
1145        let kinds: Vec<String> = events
1146            .iter()
1147            .map(|event| match event {
1148                crate::memory::events::MemoryTimelineEvent::TaintTransition { kind, .. } => {
1149                    kind.clone()
1150                }
1151                other => panic!("unexpected event {other:?}"),
1152            })
1153            .collect();
1154        assert_eq!(kinds, vec!["tainted", "reset_boundary", "rotated_clean"]);
1155    }
1156
1157    #[test]
1158    fn content_trust_parse_rejects_unknown_fields_and_bad_types() {
1159        let err = ContentTrustConfig::from_json_value(&json!({"servers": []}))
1160            .expect_err("unknown field must fail loud");
1161        assert!(err.contains("unsupported content_trust fields"), "{err}");
1162        let err = ContentTrustConfig::from_json_value(&json!({"trusted_mcp_servers": "kg"}))
1163            .expect_err("non-array must fail loud");
1164        assert!(err.contains("must be an array"), "{err}");
1165        let err = ContentTrustConfig::from_json_value(&json!({"untrusted_tools": [1]}))
1166            .expect_err("non-string entry must fail loud");
1167        assert!(err.contains("non-empty strings"), "{err}");
1168        let err =
1169            ContentTrustConfig::from_json_value(&json!([])).expect_err("non-object must fail loud");
1170        assert!(err.contains("must be an object"), "{err}");
1171    }
1172
1173    #[test]
1174    fn content_trust_parse_accepts_full_block() {
1175        let config = ContentTrustConfig::from_json_value(&json!({
1176            "trusted_mcp_servers": ["knowledge_graph"],
1177            "untrusted_tools": ["scrape_page"],
1178            "trusted_tools": ["mcp__scanner__lint"],
1179        }))
1180        .expect("valid block parses");
1181        assert_eq!(config.trusted_mcp_servers, vec!["knowledge_graph"]);
1182        assert_eq!(config.untrusted_tools, vec!["scrape_page"]);
1183        assert_eq!(config.trusted_tools, vec!["mcp__scanner__lint"]);
1184    }
1185
1186    #[test]
1187    fn classification_precedence_holds() {
1188        let config = ContentTrustConfig {
1189            trusted_mcp_servers: vec!["kg".to_string()],
1190            untrusted_tools: vec!["scrape_page".to_string()],
1191            // Web builtins are never overridable.
1192            trusted_tools: vec!["web_search".to_string(), "mcp__evil__probe".to_string()],
1193        };
1194        assert!(matches!(
1195            config.classify_tool("web_search"),
1196            ToolContentTrust::Untrusted { .. }
1197        ));
1198        assert!(matches!(
1199            config.classify_tool("scrape_page"),
1200            ToolContentTrust::Untrusted { .. }
1201        ));
1202        // Explicit per-tool trust overrides server-level distrust.
1203        assert_eq!(
1204            config.classify_tool("mcp__evil__probe"),
1205            ToolContentTrust::Trusted
1206        );
1207        // MCP untrusted by default; allowlisted server trusted.
1208        assert!(matches!(
1209            config.classify_tool("mcp__other__search"),
1210            ToolContentTrust::Untrusted { .. }
1211        ));
1212        assert_eq!(
1213            config.classify_tool("mcp__kg__query"),
1214            ToolContentTrust::Trusted
1215        );
1216        // Unknown plain names are trusted in P1 (documented coarseness).
1217        assert_eq!(config.classify_tool("shell"), ToolContentTrust::Trusted);
1218    }
1219
1220    // (b) The dispatch-time join: typed MCP provenance attributes a tool to
1221    // its server even when the NAME is not server-qualified - the exact gap
1222    // the name-based path could not close.
1223    #[test]
1224    fn typed_mcp_provenance_attributes_unqualified_tool_names() {
1225        use meerkat_core::types::{ToolProvenance, ToolSourceKind};
1226        let config = ContentTrustConfig {
1227            trusted_mcp_servers: vec!["kg".to_string()],
1228            ..ContentTrustConfig::default()
1229        };
1230        let kg = ToolProvenance {
1231            kind: ToolSourceKind::Mcp,
1232            source_id: "kg".into(),
1233        };
1234        let scraper = ToolProvenance {
1235            kind: ToolSourceKind::Mcp,
1236            source_id: "scraper".into(),
1237        };
1238        // Plain name, untrusted server: provenance closes the attribution.
1239        let verdict = config.classify_tool_with_provenance("scrape_page", Some(&scraper));
1240        match verdict {
1241            ToolContentTrust::Untrusted { source } => {
1242                assert!(source.contains("MCP server 'scraper'"), "{source}");
1243                assert!(source.contains("scrape_page"), "{source}");
1244            }
1245            ToolContentTrust::Trusted => panic!("untrusted-server MCP tool must taint"),
1246        }
1247        // Plain name, allowlisted server: trusted through the same join.
1248        assert_eq!(
1249            config.classify_tool_with_provenance("query", Some(&kg)),
1250            ToolContentTrust::Trusted
1251        );
1252        // Typed provenance wins over a misleading name shape: the server in
1253        // the provenance is the owner, not the name's `mcp__` segment.
1254        assert_eq!(
1255            config.classify_tool_with_provenance("mcp__evil__query", Some(&kg)),
1256            ToolContentTrust::Trusted
1257        );
1258        // Precedence is preserved around the widened MCP step: web builtins
1259        // and explicit per-tool lists still outrank provenance.
1260        assert!(matches!(
1261            config.classify_tool_with_provenance("web_search", Some(&kg)),
1262            ToolContentTrust::Untrusted { .. }
1263        ));
1264        let listed = ContentTrustConfig {
1265            trusted_tools: vec!["scrape_page".to_string()],
1266            ..ContentTrustConfig::default()
1267        };
1268        assert_eq!(
1269            listed.classify_tool_with_provenance("scrape_page", Some(&scraper)),
1270            ToolContentTrust::Trusted,
1271            "explicit trusted_tools overrides server-level distrust, as on the name path"
1272        );
1273    }
1274
1275    // (c) Absence fallback: no provenance (and non-MCP provenance) must
1276    // reproduce the name-based classification exactly.
1277    #[test]
1278    fn absent_or_non_mcp_provenance_falls_back_to_name_classification() {
1279        use meerkat_core::types::{ToolProvenance, ToolSourceKind};
1280        let config = ContentTrustConfig {
1281            trusted_mcp_servers: vec!["kg".to_string()],
1282            untrusted_tools: vec!["scrape_page".to_string()],
1283            trusted_tools: vec!["mcp__evil__probe".to_string()],
1284        };
1285        for name in [
1286            "web_search",
1287            "scrape_page",
1288            "mcp__evil__probe",
1289            "mcp__other__search",
1290            "mcp__kg__query",
1291            "shell",
1292        ] {
1293            assert_eq!(
1294                config.classify_tool_with_provenance(name, None),
1295                config.classify_tool(name),
1296                "provenance-absent classification must match the name path for '{name}'"
1297            );
1298        }
1299        // Non-MCP provenance kinds keep the name-shape semantics too.
1300        let builtin = ToolProvenance {
1301            kind: ToolSourceKind::Builtin,
1302            source_id: "builtin".into(),
1303        };
1304        assert_eq!(
1305            config.classify_tool_with_provenance("shell", Some(&builtin)),
1306            ToolContentTrust::Trusted
1307        );
1308        assert_eq!(
1309            config.classify_tool_with_provenance("mcp__other__search", Some(&builtin)),
1310            config.classify_tool("mcp__other__search")
1311        );
1312    }
1313
1314    #[test]
1315    fn dispatched_tool_result_marks_identity_before_any_session_attribution() {
1316        use meerkat_core::types::{ToolProvenance, ToolSourceKind};
1317        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1318        let provenance = ToolProvenance {
1319            kind: ToolSourceKind::Mcp,
1320            source_id: "scraper".into(),
1321        };
1322        // No RunStarted, no delivery hook: the dispatch feed must still land
1323        // (identity-sticky pending), so the same-turn write gate sees it.
1324        tracker.observe_dispatched_tool_result("identity:a", "scrape_page", Some(&provenance));
1325        let taint = tracker
1326            .identity_taint("identity:a")
1327            .expect("dispatch-time mark must be visible to the gate immediately");
1328        assert!(
1329            taint.source.contains("MCP server 'scraper'"),
1330            "{}",
1331            taint.source
1332        );
1333
1334        // Trusted results do not mark.
1335        tracker.observe_dispatched_tool_result("identity:b", "shell", None);
1336        assert!(tracker.identity_taint("identity:b").is_none());
1337
1338        // Server tools are always untrusted.
1339        tracker.observe_dispatched_server_tool("identity:c", &ServerToolKind::WebSearch);
1340        let taint = tracker
1341            .identity_taint("identity:c")
1342            .expect("server tool marks");
1343        assert!(taint.source.contains("web_search"), "{}", taint.source);
1344    }
1345
1346    #[test]
1347    fn tracker_taints_on_untrusted_tool_and_clears_on_rotation() {
1348        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1349        let session = SessionId::new();
1350        tracker.observe_agent_event("identity:a", &run_started(&session));
1351        assert!(tracker.identity_taint("identity:a").is_none());
1352
1353        tracker.observe_agent_event("identity:a", &tool_result("shell"));
1354        assert!(tracker.identity_taint("identity:a").is_none());
1355
1356        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1357        let taint = tracker
1358            .identity_taint("identity:a")
1359            .expect("web tool result taints the session");
1360        assert!(taint.source.contains("web_search"), "{}", taint.source);
1361        assert!(tracker.session_taint(&session.to_string()).is_some());
1362
1363        // Session-sticky: a later benign event does not clear.
1364        tracker.observe_agent_event("identity:a", &tool_result("shell"));
1365        assert!(tracker.identity_taint("identity:a").is_some());
1366
1367        // Rotation (reset/respawn/fresh spawn mint a new session id) clears.
1368        let fresh = SessionId::new();
1369        tracker.observe_agent_event("identity:a", &run_started(&fresh));
1370        assert!(tracker.identity_taint("identity:a").is_none());
1371        // The old session's fact remains recorded (P2 comms joins read it).
1372        assert!(tracker.session_taint(&session.to_string()).is_some());
1373    }
1374
1375    #[test]
1376    fn tracker_taints_on_server_tool_content() {
1377        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1378        let session = SessionId::new();
1379        tracker.note_current_session("identity:a", &session.to_string());
1380        tracker.observe_agent_event(
1381            "identity:a",
1382            &AgentEvent::ServerToolContent {
1383                id: None,
1384                kind: ServerToolKind::WebSearch,
1385                content: json!({"results": []}),
1386            },
1387        );
1388        let taint = tracker.identity_taint("identity:a").expect("taints");
1389        assert!(taint.source.contains("web_search"), "{}", taint.source);
1390    }
1391
1392    #[test]
1393    fn pre_attribution_taint_holds_identity_sticky_then_transfers() {
1394        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1395        // Tool event before any RunStarted (mid-run attach).
1396        tracker.observe_agent_event("identity:a", &tool_result("fetch"));
1397        assert!(tracker.identity_taint("identity:a").is_some());
1398
1399        // The next attributed session inherits the pending taint.
1400        let session = SessionId::new();
1401        tracker.observe_agent_event("identity:a", &run_started(&session));
1402        assert!(tracker.session_taint(&session.to_string()).is_some());
1403        assert!(tracker.identity_taint("identity:a").is_some());
1404    }
1405
1406    #[test]
1407    fn clear_identity_drops_attribution_but_keeps_the_session_fact() {
1408        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1409        let session = SessionId::new();
1410        tracker.observe_agent_event("identity:a", &run_started(&session));
1411        tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1412        assert!(tracker.identity_taint("identity:a").is_some());
1413        tracker.clear_identity("identity:a");
1414        // The identity moves on clean...
1415        assert!(tracker.identity_taint("identity:a").is_none());
1416        // ...but the historical fact survives: the comms join and the
1417        // evidence-range gate consult exactly such sessions (P2).
1418        assert!(tracker.session_taint(&session.to_string()).is_some());
1419        assert!(
1420            tracker
1421                .evidence_quarantine_reason(&session.to_string())
1422                .is_some()
1423        );
1424    }
1425
1426    #[test]
1427    fn outbound_declarer_stamps_tainted_on_ingestion_and_clears_on_boundaries() {
1428        use std::sync::{Arc, Mutex};
1429        type Recorded = Vec<(String, Option<meerkat_core::comms::SenderContentTaint>)>;
1430        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1431        let calls: Arc<Mutex<Recorded>> = Arc::new(Mutex::new(Vec::new()));
1432        let sink = calls.clone();
1433        tracker.set_outbound_taint_declarer(Arc::new(move |identity, taint| {
1434            sink.lock()
1435                .unwrap_or_else(std::sync::PoisonError::into_inner)
1436                .push((identity.to_string(), taint));
1437        }));
1438
1439        let session = SessionId::new();
1440        tracker.observe_agent_event("identity:a", &run_started(&session));
1441        // A benign tool makes no declaration.
1442        tracker.observe_agent_event("identity:a", &tool_result("shell"));
1443        assert!(calls.lock().unwrap().is_empty());
1444
1445        // Untrusted ingestion stamps the member Tainted for outbound peer sends.
1446        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1447        {
1448            let recorded = calls.lock().unwrap();
1449            assert_eq!(recorded.len(), 1, "one declaration expected: {recorded:?}");
1450            assert_eq!(recorded[0].0, "identity:a");
1451            assert_eq!(
1452                recorded[0].1,
1453                Some(meerkat_core::comms::SenderContentTaint::Tainted)
1454            );
1455        }
1456
1457        // Rotation to a fresh clean session clears the declaration (None, not
1458        // Clean — an absent declaration, never coalesced into clean).
1459        let fresh = SessionId::new();
1460        tracker.note_current_session("identity:a", &fresh.to_string());
1461        {
1462            let recorded = calls.lock().unwrap();
1463            assert_eq!(recorded.len(), 2, "rotation clears: {recorded:?}");
1464            assert_eq!(recorded[1].1, None);
1465        }
1466
1467        // Reset (the operator escape hatch) is also a fresh-context boundary.
1468        tracker.observe_agent_event("identity:a", &tool_result("web_fetch"));
1469        tracker.clear_identity("identity:a");
1470        {
1471            let recorded = calls.lock().unwrap();
1472            assert_eq!(
1473                recorded.last().expect("a clear declaration").1,
1474                None,
1475                "reset clears the outbound declaration: {recorded:?}"
1476            );
1477        }
1478    }
1479
1480    #[test]
1481    fn gate_quarantines_tainted_agents_and_quarantined_policy() {
1482        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1483        let session = SessionId::new();
1484        tracker.observe_agent_event("identity:a", &run_started(&session));
1485
1486        let gate = TaintLlmWriteGate::new(Some(tracker.clone()), AgentMemoryLlmWrites::Observed);
1487        let agent = MemoryAuthor::Agent {
1488            identity: "identity:a".to_string(),
1489        };
1490        assert!(
1491            gate.quarantine_reason(&agent, StagedBatchKind::FreshWrite, &[])
1492                .is_none()
1493        );
1494        assert!(
1495            gate.quarantine_reason(&MemoryAuthor::Application, StagedBatchKind::FreshWrite, &[])
1496                .is_none()
1497        );
1498
1499        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1500        let reason = gate
1501            .quarantine_reason(&agent, StagedBatchKind::FreshWrite, &[])
1502            .expect("tainted quarantines");
1503        assert!(reason.contains("session tainted"), "{reason}");
1504        // Non-LLM principals are never gated, tainted or not.
1505        assert!(
1506            gate.quarantine_reason(&MemoryAuthor::Application, StagedBatchKind::FreshWrite, &[])
1507                .is_none()
1508        );
1509        assert!(
1510            gate.quarantine_reason(&MemoryAuthor::Operator, StagedBatchKind::FreshWrite, &[])
1511                .is_none()
1512        );
1513
1514        // llm_writes=quarantined forces quarantine with no taint at all —
1515        // for every first-pass LLM write (Agent, Distiller, and the
1516        // steward's own fresh consolidate/harvest/rank output).
1517        let strict = TaintLlmWriteGate::new(None, AgentMemoryLlmWrites::Quarantined);
1518        let reason = strict
1519            .quarantine_reason(
1520                &MemoryAuthor::Agent {
1521                    identity: "identity:clean".to_string(),
1522                },
1523                StagedBatchKind::FreshWrite,
1524                &[],
1525            )
1526            .expect("policy quarantines untainted writes");
1527        assert!(reason.contains("llm_writes=quarantined"), "{reason}");
1528        assert!(
1529            strict
1530                .quarantine_reason(
1531                    &MemoryAuthor::Distiller {
1532                        run_id: "run-1".to_string()
1533                    },
1534                    StagedBatchKind::FreshWrite,
1535                    &[]
1536                )
1537                .is_some()
1538        );
1539        let steward = MemoryAuthor::Steward {
1540            run_id: "run-1".to_string(),
1541        };
1542        assert!(
1543            strict
1544                .quarantine_reason(&steward, StagedBatchKind::FreshWrite, &[])
1545                .is_some(),
1546            "fresh steward LLM output (consolidate/harvest/rank) respects the posture"
1547        );
1548        // A review verdict IS the review the posture defers to: the posture
1549        // branch must not re-quarantine releases, approved promotions, and
1550        // proposal accepts (they would otherwise never produce an Active
1551        // record). Keyed on the batch kind, not the Steward author.
1552        assert!(
1553            strict
1554                .quarantine_reason(&steward, StagedBatchKind::ReviewVerdict, &[])
1555                .is_none()
1556        );
1557        assert!(
1558            strict
1559                .quarantine_reason(&MemoryAuthor::Operator, StagedBatchKind::FreshWrite, &[])
1560                .is_none()
1561        );
1562    }
1563
1564    #[test]
1565    fn quarantined_posture_still_gates_review_verdicts_on_tainted_evidence() {
1566        // The review-verdict exemption is posture-branch-only: a review
1567        // batch citing a tainted session still quarantines through the
1568        // evidence-range branch (§10.1).
1569        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1570        let session = SessionId::new();
1571        tracker.observe_agent_event("identity:a", &run_started(&session));
1572        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1573
1574        let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Quarantined);
1575        let steward = MemoryAuthor::Steward {
1576            run_id: "run-1".to_string(),
1577        };
1578        assert!(
1579            gate.quarantine_reason(&steward, StagedBatchKind::ReviewVerdict, &[])
1580                .is_none()
1581        );
1582        let reason = gate
1583            .quarantine_reason(
1584                &steward,
1585                StagedBatchKind::ReviewVerdict,
1586                &evidence_for(&session),
1587            )
1588            .expect("tainted evidence still quarantines review verdicts");
1589        assert!(reason.contains("evidence session tainted"), "{reason}");
1590    }
1591
1592    fn evidence_for(session: &SessionId) -> Vec<EvidenceRef> {
1593        vec![EvidenceRef {
1594            session_id: session.to_string(),
1595            generation: 0,
1596            revision: None,
1597            range: Some((0, 4)),
1598        }]
1599    }
1600
1601    #[test]
1602    fn gate_quarantines_llm_writes_citing_tainted_evidence() {
1603        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1604        let session = SessionId::new();
1605        tracker.observe_agent_event("identity:a", &run_started(&session));
1606        tracker.observe_agent_event("identity:a", &tool_result("web_search"));
1607        // Identity rotates away: the identity is clean, the session fact
1608        // remains — the distiller's evidence must still quarantine.
1609        let fresh = SessionId::new();
1610        tracker.observe_agent_event("identity:a", &run_started(&fresh));
1611
1612        let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Observed);
1613        let distiller = MemoryAuthor::Distiller {
1614            run_id: "run-1".to_string(),
1615        };
1616        let reason = gate
1617            .quarantine_reason(
1618                &distiller,
1619                StagedBatchKind::FreshWrite,
1620                &evidence_for(&session),
1621            )
1622            .expect("tainted evidence range quarantines (session-tainted ⇒ range-tainted)");
1623        assert!(reason.contains("evidence session tainted"), "{reason}");
1624        // Clean evidence does not.
1625        assert!(
1626            gate.quarantine_reason(
1627                &distiller,
1628                StagedBatchKind::FreshWrite,
1629                &evidence_for(&fresh)
1630            )
1631            .is_none()
1632        );
1633        // Non-LLM authors are never evidence-gated.
1634        assert!(
1635            gate.quarantine_reason(
1636                &MemoryAuthor::Operator,
1637                StagedBatchKind::FreshWrite,
1638                &evidence_for(&session)
1639            )
1640            .is_none()
1641        );
1642    }
1643
1644    #[test]
1645    fn reset_boundary_quarantines_evidence_without_content_taint() {
1646        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1647        let session = SessionId::new();
1648        tracker.observe_agent_event("identity:a", &run_started(&session));
1649        assert!(
1650            tracker
1651                .evidence_quarantine_reason(&session.to_string())
1652                .is_none()
1653        );
1654        tracker.mark_reset_boundary(&session.to_string());
1655        let gate = TaintLlmWriteGate::new(Some(tracker), AgentMemoryLlmWrites::Observed);
1656        let reason = gate
1657            .quarantine_reason(
1658                &MemoryAuthor::Distiller {
1659                    run_id: "run-1".to_string(),
1660                },
1661                StagedBatchKind::FreshWrite,
1662                &evidence_for(&session),
1663            )
1664            .expect("reset boundary quarantines distillates");
1665        assert!(reason.contains("reset boundary"), "{reason}");
1666    }
1667
1668    #[test]
1669    fn peer_projection_sender_parses_message_and_response_shapes() {
1670        assert_eq!(
1671            peer_projection_sender_identity("Peer message from mob-1/worker/identity:bob:"),
1672            Some("identity:bob")
1673        );
1674        assert_eq!(
1675            peer_projection_sender_identity(
1676                "Peer response from mob-1/worker/identity:bob (to request: req-9)"
1677            ),
1678            Some("identity:bob")
1679        );
1680        // External peers may have plain display names.
1681        assert_eq!(
1682            peer_projection_sender_identity("Peer message from scout:"),
1683            Some("scout")
1684        );
1685        // Peer requests render a raw peer id — unmappable, and honestly so.
1686        assert_eq!(
1687            peer_projection_sender_identity("Peer request from peer_id 018fabc (id: r-1)"),
1688            None
1689        );
1690        assert_eq!(peer_projection_sender_identity("ordinary text"), None);
1691    }
1692
1693    #[test]
1694    fn comms_join_taints_receiver_of_message_from_tainted_sender() {
1695        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
1696        // Sender taints its session.
1697        let sender_session = SessionId::new();
1698        tracker.observe_agent_event("identity:bob", &run_started(&sender_session));
1699        tracker.observe_agent_event("identity:bob", &tool_result("web_search"));
1700
1701        // Receiver gets a peer message from the tainted sender: the run's
1702        // injected input carries the canonical projection text.
1703        let receiver_session = SessionId::new();
1704        let delivery = AgentEvent::RunStarted {
1705            session_id: receiver_session.clone(),
1706            input: meerkat_core::types::RunInput::Content {
1707                content: meerkat_core::ContentInput::Text(
1708                    "Peer message from mob-1/worker/identity:bob:\nplease remember X".to_string(),
1709                ),
1710            },
1711        };
1712        tracker.observe_agent_event("identity:alice", &delivery);
1713        let taint = tracker
1714            .identity_taint("identity:alice")
1715            .expect("receiver session taints (peer-laundering close, §10.1)");
1716        assert!(taint.source.contains("identity:bob"), "{}", taint.source);
1717        assert!(
1718            tracker
1719                .session_taint(&receiver_session.to_string())
1720                .is_some()
1721        );
1722
1723        // A message from a clean tracked sender does not taint.
1724        let clean_session = SessionId::new();
1725        tracker.observe_agent_event("identity:carol", &run_started(&clean_session));
1726        let receiver2 = SessionId::new();
1727        let clean_delivery = AgentEvent::RunStarted {
1728            session_id: receiver2.clone(),
1729            input: meerkat_core::types::RunInput::Content {
1730                content: meerkat_core::ContentInput::Text(
1731                    "Peer message from mob-1/worker/identity:carol:\nhello".to_string(),
1732                ),
1733            },
1734        };
1735        tracker.observe_agent_event("identity:dave", &clean_delivery);
1736        assert!(tracker.identity_taint("identity:dave").is_none());
1737    }
1738}