Skip to main content

meerkat_mobkit/memory/
dispatch_taint.rs

1//! Dispatch-ordered content-trust marking for mob member agents (ยง10.1,
2//! closes the first-ingestion race - see the `taint` module docs).
3//!
4//! The observe-only agent-event stream is asynchronous, so a memory write in
5//! the same turn as the session's FIRST untrusted tool ingestion could reach
6//! the store before the taint observer processed the tool event. This module
7//! joins content trust into the member's synchronous execution path instead:
8//!
9//! - meerkat 0.8.14 fires `HookPoint::PostToolExecution` synchronously with
10//!   the typed `ToolProvenance` (the loop blocks on the report:
11//!   meerkat-core/src/agent/state.rs:5010-5035 at the 0.8.14 pin, payload
12//!   provenance at meerkat-core/src/hooks.rs:273), but the mob member build
13//!   path has no hook-engine carrier: `SessionBuildOptions` cannot ship an
14//!   `Arc<dyn HookEngine>`, `FactoryAgentBuilder` injects no hook-engine
15//!   default (meerkat/src/service_factory.rs `build_agent`), and
16//!   `AgentBuildConfig.hook_engine_override` is set only by the standalone
17//!   facade builder (meerkat/src/agent_builder.rs:187) - never reachable
18//!   from `MobSessionService` member creates. Re-audit those seams when a
19//!   hook slot lands upstream; this module then collapses onto it. The
20//!   sanctioned per-build seam that IS synchronous with the loop is
21//!   `SessionBuildOptions.agent_llm_client_decorator`.
22//! - [`TaintObservingLlmClient`] therefore wraps the member's final
23//!   agent-facing LLM client. Before delegating each call it classifies the
24//!   tool results newly present in the request - joining the tool name
25//!   against the request's typed `ToolDef.provenance` catalog - and marks
26//!   the [`SessionTaintTracker`]. After the call returns it classifies the
27//!   typed `ServerToolContent` blocks (provider-executed web search /
28//!   grounding) before the loop can see them.
29//!
30//! Ordering guarantee: an LLM-authored memory write is a tool call in some
31//! response R, and content derived from an untrusted tool result T can only
32//! appear in R if T rode the request that produced R - which this wrapper
33//! classified before sending. Provider-executed server tools are classified
34//! before the loop can dispatch any same-response tool call. Either way the
35//! tracker is marked strictly before the write reaches the store's gate.
36//! The async observer stays wired as belt-and-suspenders (it also serves
37//! session-rotation mirroring); this join simply gets there first.
38//!
39//! The hook path is observe-and-mark only: it never denies, never mutates
40//! the request or response, and does no I/O (the tracker is in-memory).
41
42use std::collections::HashMap;
43use std::sync::{Arc, Mutex, RwLock};
44
45use meerkat_core::service::{CreateSessionRequest, SessionBuildOptions};
46use meerkat_core::types::{AssistantBlock, Message, ToolDef};
47use meerkat_core::{
48    AgentError, AgentLlmClient, AgentLlmFallbackSwitch, CompiledSchema, LlmStreamResult,
49    OutputSchema, ProviderParamsOverride, ProviderRequestPressure, SchemaError, SessionLlmIdentity,
50};
51
52use crate::member_comms_id;
53use crate::memory::taint::SessionTaintTracker;
54
55/// Late-bound tracker slot shared between the member pre-build seam (which
56/// installs the decorator at bootstrap, before the memory stack exists) and
57/// the memory-stack attach (which fills it). Cheap to clone; clones share
58/// the slot. An unfilled slot makes every installed decorator a pure
59/// pass-through, so compositions without the taint firewall pay nothing.
60#[derive(Clone, Default)]
61pub struct DispatchTaintSlot {
62    inner: Arc<RwLock<Option<SessionTaintTracker>>>,
63}
64
65impl DispatchTaintSlot {
66    /// Bind the live tracker. Called once when the memory stack attaches;
67    /// decorators installed earlier (bootstrap members) pick it up on their
68    /// next LLM call because they read the slot per call.
69    pub fn fill(&self, tracker: SessionTaintTracker) {
70        *self
71            .inner
72            .write()
73            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(tracker);
74    }
75
76    fn tracker(&self) -> Option<SessionTaintTracker> {
77        self.inner
78            .read()
79            .unwrap_or_else(std::sync::PoisonError::into_inner)
80            .clone()
81    }
82}
83
84impl std::fmt::Debug for DispatchTaintSlot {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("DispatchTaintSlot")
87            .field("filled", &self.tracker().is_some())
88            .finish()
89    }
90}
91
92/// Resolve the tracker identity for one member session create, in the
93/// spelling the write gate keys on (`MemoryAuthor::Agent { identity }`).
94///
95/// The mob member binding is the one honest source: meerkat-mob stamps it on
96/// every member build, and it also overwrites the `agent_identity` label
97/// with the encoded roster id, so labels carry no extra information here.
98/// Decoding the roster id yields the public alias; identity-first internal
99/// members roster under their generated runtime alias
100/// (`rt:{identity}:{generation}`), which normalizes to the durable identity.
101fn member_taint_identity(req: &CreateSessionRequest) -> Option<String> {
102    let binding = req.build.as_ref()?.mob_member_binding.as_ref()?;
103    Some(member_comms_id::logical_memory_identity(&binding.member))
104}
105
106/// Install (or compose over) the request's LLM-client decorator so the built
107/// member agent carries the dispatch-time taint join. No-op for requests
108/// that are not member builds (no mob member binding) - the
109/// bridge/supervisor session keeps its plain client.
110pub(crate) fn attach_member_taint_decorator(
111    req: &mut CreateSessionRequest,
112    slot: &DispatchTaintSlot,
113) {
114    let Some(identity) = member_taint_identity(req) else {
115        return;
116    };
117    // `member_taint_identity` proved `build` present (the binding rides it).
118    let build = req.build.get_or_insert_with(SessionBuildOptions::default);
119    let prior = build.agent_llm_client_decorator.take();
120    let slot = slot.clone();
121    build.agent_llm_client_decorator = Some(Arc::new(move |client| {
122        let client = match prior.as_ref() {
123            Some(prior) => prior(client),
124            None => client,
125        };
126        Arc::new(TaintObservingLlmClient::new(
127            client,
128            identity.clone(),
129            slot.clone(),
130        ))
131    }));
132}
133
134/// Observe-and-mark wrapper over the member's final agent-facing LLM client.
135/// Never denies, never mutates: every method delegates verbatim; the only
136/// side effect is marking the in-memory taint tracker.
137pub struct TaintObservingLlmClient {
138    inner: Arc<dyn AgentLlmClient>,
139    identity: String,
140    slot: DispatchTaintSlot,
141    /// Messages already classified. The transcript is append-only within a
142    /// session; a shrink (compaction rebuild) resets the cursor and rescans
143    /// - marking is idempotent, so a rescan only costs time.
144    scanned: Mutex<usize>,
145}
146
147impl TaintObservingLlmClient {
148    pub fn new(inner: Arc<dyn AgentLlmClient>, identity: String, slot: DispatchTaintSlot) -> Self {
149        Self {
150            inner,
151            identity,
152            slot,
153            scanned: Mutex::new(0),
154        }
155    }
156
157    /// Classify every tool result newly appended since the last call. Tool
158    /// results carry only `tool_use_id`; the paired assistant `ToolUse`
159    /// block (same appended window - results never precede their call)
160    /// supplies the name, and the request's tool catalog supplies the typed
161    /// provenance for the dispatch-time MCP attribution.
162    fn mark_request_ingestions(
163        &self,
164        tracker: &SessionTaintTracker,
165        messages: &[Message],
166        tools: &[Arc<ToolDef>],
167    ) {
168        let mut scanned = self
169            .scanned
170            .lock()
171            .unwrap_or_else(std::sync::PoisonError::into_inner);
172        let start = if *scanned > messages.len() {
173            0
174        } else {
175            *scanned
176        };
177        let mut names: HashMap<&str, &str> = HashMap::new();
178        for message in &messages[start..] {
179            match message {
180                Message::BlockAssistant(assistant) => {
181                    for block in &assistant.blocks {
182                        match block {
183                            AssistantBlock::ToolUse { id, name, .. } => {
184                                names.insert(id.as_str(), name.as_str());
185                            }
186                            // Server-tool evidence persisted into the
187                            // transcript: re-marks idempotently, and covers
188                            // resumed sessions whose in-memory taint state
189                            // was lost with the process.
190                            AssistantBlock::ServerToolContent { kind, .. } => {
191                                tracker.observe_dispatched_server_tool(&self.identity, kind);
192                            }
193                            _ => {}
194                        }
195                    }
196                }
197                Message::ToolResults { results, .. } => {
198                    for result in results {
199                        // A result whose call fell outside the window has no
200                        // name to classify on; the observe-stream fallback
201                        // still covers it. Errors classify like successes -
202                        // an error body is attacker-influenced text too.
203                        let Some(name) = names.get(result.tool_use_id.as_str()) else {
204                            continue;
205                        };
206                        let provenance = tools
207                            .iter()
208                            .find(|tool| tool.name.as_ref() == *name)
209                            .and_then(|tool| tool.provenance.as_ref());
210                        tracker.observe_dispatched_tool_result(&self.identity, name, provenance);
211                    }
212                }
213                _ => {}
214            }
215        }
216        *scanned = messages.len();
217    }
218}
219
220#[async_trait::async_trait]
221impl AgentLlmClient for TaintObservingLlmClient {
222    async fn stream_response(
223        &self,
224        messages: &[Message],
225        tools: &[Arc<ToolDef>],
226        max_tokens: u32,
227        temperature: Option<f32>,
228        provider_params: Option<&ProviderParamsOverride>,
229    ) -> Result<LlmStreamResult, AgentError> {
230        if let Some(tracker) = self.slot.tracker() {
231            self.mark_request_ingestions(&tracker, messages, tools);
232        }
233        let result = self
234            .inner
235            .stream_response(messages, tools, max_tokens, temperature, provider_params)
236            .await?;
237        if let Some(tracker) = self.slot.tracker() {
238            for block in result.blocks() {
239                if let AssistantBlock::ServerToolContent { kind, .. } = block {
240                    tracker.observe_dispatched_server_tool(&self.identity, kind);
241                }
242            }
243        }
244        Ok(result)
245    }
246
247    fn request_pressure(
248        &self,
249        messages: &[Message],
250        tools: &[Arc<ToolDef>],
251        max_tokens: u32,
252        temperature: Option<f32>,
253        provider_params: Option<&ProviderParamsOverride>,
254    ) -> Result<Option<ProviderRequestPressure>, AgentError> {
255        self.inner
256            .request_pressure(messages, tools, max_tokens, temperature, provider_params)
257    }
258
259    fn provider(&self) -> meerkat_core::Provider {
260        self.inner.provider()
261    }
262
263    fn model(&self) -> &str {
264        self.inner.model()
265    }
266
267    fn prepare_model_fallback(&self, failure: &AgentError) -> Option<AgentLlmFallbackSwitch> {
268        self.inner.prepare_model_fallback(failure)
269    }
270
271    fn commit_model_fallback(
272        &self,
273        previous_identity: &SessionLlmIdentity,
274        target_identity: &SessionLlmIdentity,
275    ) -> Result<(), AgentError> {
276        self.inner
277            .commit_model_fallback(previous_identity, target_identity)
278    }
279
280    fn active_model_fallback_identity(&self) -> Option<SessionLlmIdentity> {
281        self.inner.active_model_fallback_identity()
282    }
283
284    fn compile_model_fallback_schema(
285        &self,
286        target_identity: &SessionLlmIdentity,
287        output_schema: &OutputSchema,
288    ) -> Result<CompiledSchema, AgentError> {
289        self.inner
290            .compile_model_fallback_schema(target_identity, output_schema)
291    }
292
293    fn begin_stream_output_observation(&self) {
294        self.inner.begin_stream_output_observation();
295    }
296
297    fn stream_output_observed(&self) -> bool {
298        self.inner.stream_output_observed()
299    }
300
301    fn stream_activity_count(&self) -> Option<u64> {
302        self.inner.stream_activity_count()
303    }
304
305    fn compile_schema(&self, output_schema: &OutputSchema) -> Result<CompiledSchema, SchemaError> {
306        self.inner.compile_schema(output_schema)
307    }
308}
309
310#[cfg(test)]
311#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
312mod tests {
313    use super::*;
314    use crate::memory::taint::ContentTrustConfig;
315    use meerkat_core::types::{
316        BlockAssistantMessage, ContentBlock, ServerToolKind, StopReason, ToolProvenance,
317        ToolResult, ToolSourceKind, Usage,
318    };
319    use serde_json::value::RawValue;
320
321    struct ScriptedInner {
322        blocks: Vec<AssistantBlock>,
323    }
324
325    #[async_trait::async_trait]
326    impl AgentLlmClient for ScriptedInner {
327        async fn stream_response(
328            &self,
329            _messages: &[Message],
330            _tools: &[Arc<ToolDef>],
331            _max_tokens: u32,
332            _temperature: Option<f32>,
333            _provider_params: Option<&ProviderParamsOverride>,
334        ) -> Result<LlmStreamResult, AgentError> {
335            Ok(LlmStreamResult::new(
336                self.blocks.clone(),
337                StopReason::EndTurn,
338                Usage::default(),
339            ))
340        }
341
342        fn provider(&self) -> meerkat_core::Provider {
343            meerkat_core::Provider::OpenAI
344        }
345
346        fn model(&self) -> &'static str {
347            "gpt-5.5"
348        }
349    }
350
351    fn tool_use(id: &str, name: &str) -> AssistantBlock {
352        AssistantBlock::ToolUse {
353            id: id.to_string(),
354            name: name.to_string(),
355            args: RawValue::from_string("{}".to_string()).expect("raw args"),
356            meta: None,
357        }
358    }
359
360    fn assistant(blocks: Vec<AssistantBlock>) -> Message {
361        Message::BlockAssistant(BlockAssistantMessage::new(blocks, StopReason::ToolUse))
362    }
363
364    fn tool_results(id: &str, text: &str) -> Message {
365        Message::tool_results(vec![ToolResult {
366            tool_use_id: id.to_string(),
367            content: vec![ContentBlock::Text {
368                text: text.to_string(),
369            }],
370            is_error: false,
371        }])
372    }
373
374    fn mcp_tool(name: &str, server: &str) -> Arc<ToolDef> {
375        Arc::new(ToolDef {
376            name: name.into(),
377            description: String::new(),
378            input_schema: serde_json::json!({"type": "object"}),
379            provenance: Some(ToolProvenance {
380                kind: ToolSourceKind::Mcp,
381                source_id: server.into(),
382            }),
383        })
384    }
385
386    async fn drive(client: &TaintObservingLlmClient, messages: &[Message], tools: &[Arc<ToolDef>]) {
387        client
388            .stream_response(messages, tools, 128, None, None)
389            .await
390            .expect("scripted call succeeds");
391    }
392
393    // The decorator marks an unqualified MCP tool via typed provenance from
394    // the request catalog, synchronously with the call that carries the
395    // result - the gate sees taint with no observer in the process at all.
396    #[tokio::test]
397    async fn marks_unqualified_mcp_tool_via_request_catalog_provenance() {
398        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
399        let slot = DispatchTaintSlot::default();
400        slot.fill(tracker.clone());
401        let client = TaintObservingLlmClient::new(
402            Arc::new(ScriptedInner { blocks: vec![] }),
403            "identity:a".to_string(),
404            slot,
405        );
406
407        let messages = vec![
408            assistant(vec![tool_use("call-1", "scrape_page")]),
409            tool_results("call-1", "attacker text"),
410        ];
411        let tools = vec![mcp_tool("scrape_page", "scraper")];
412        drive(&client, &messages, &tools).await;
413
414        let taint = tracker
415            .identity_taint("identity:a")
416            .expect("MCP result must mark before the call proceeds");
417        assert!(
418            taint.source.contains("MCP server 'scraper'"),
419            "{}",
420            taint.source
421        );
422    }
423
424    // Absence fallback at the decorator level: a catalog entry without
425    // provenance classifies by name - a plain trusted name does not mark, an
426    // always-untrusted web name does.
427    #[tokio::test]
428    async fn falls_back_to_name_classification_without_provenance() {
429        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
430        let slot = DispatchTaintSlot::default();
431        slot.fill(tracker.clone());
432        let client = TaintObservingLlmClient::new(
433            Arc::new(ScriptedInner { blocks: vec![] }),
434            "identity:a".to_string(),
435            slot,
436        );
437
438        let plain = Arc::new(ToolDef {
439            name: "lookup".into(),
440            description: String::new(),
441            input_schema: serde_json::json!({"type": "object"}),
442            provenance: None,
443        });
444        let messages = vec![
445            assistant(vec![tool_use("call-1", "lookup")]),
446            tool_results("call-1", "fine"),
447        ];
448        drive(&client, &messages, std::slice::from_ref(&plain)).await;
449        assert!(tracker.identity_taint("identity:a").is_none());
450
451        let messages = vec![
452            assistant(vec![tool_use("call-1", "lookup")]),
453            tool_results("call-1", "fine"),
454            assistant(vec![tool_use("call-2", "web_fetch")]),
455            tool_results("call-2", "attacker text"),
456        ];
457        drive(&client, &messages, std::slice::from_ref(&plain)).await;
458        assert!(
459            tracker.identity_taint("identity:a").is_some(),
460            "web builtins classify untrusted with no catalog entry at all"
461        );
462    }
463
464    // Server-tool blocks in the RESPONSE mark before the wrapper returns -
465    // i.e., before the loop can dispatch any same-response tool call.
466    #[tokio::test]
467    async fn marks_server_tool_content_from_the_response() {
468        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
469        let slot = DispatchTaintSlot::default();
470        slot.fill(tracker.clone());
471        let client = TaintObservingLlmClient::new(
472            Arc::new(ScriptedInner {
473                blocks: vec![AssistantBlock::ServerToolContent {
474                    id: None,
475                    kind: ServerToolKind::WebSearch,
476                    content: serde_json::json!({"results": []}),
477                    meta: None,
478                }],
479            }),
480            "identity:a".to_string(),
481            slot,
482        );
483        drive(&client, &[], &[]).await;
484        let taint = tracker.identity_taint("identity:a").expect("marks");
485        assert!(taint.source.contains("web_search"), "{}", taint.source);
486    }
487
488    // An unfilled slot is a pure pass-through; a late fill picks up marking
489    // without rebuilding the client (bootstrap members build before the
490    // memory stack attaches).
491    #[tokio::test]
492    async fn unfilled_slot_is_inert_and_late_fill_activates() {
493        let slot = DispatchTaintSlot::default();
494        let client = TaintObservingLlmClient::new(
495            Arc::new(ScriptedInner { blocks: vec![] }),
496            "identity:a".to_string(),
497            slot.clone(),
498        );
499        let messages = vec![
500            assistant(vec![tool_use("call-1", "web_fetch")]),
501            tool_results("call-1", "attacker text"),
502        ];
503        drive(&client, &messages, &[]).await;
504
505        let tracker = SessionTaintTracker::new(ContentTrustConfig::default());
506        slot.fill(tracker.clone());
507        assert!(tracker.identity_taint("identity:a").is_none());
508        // The cursor never advanced while unfilled: the same history is
509        // classified on the first tracked call.
510        drive(&client, &messages, &[]).await;
511        assert!(tracker.identity_taint("identity:a").is_some());
512    }
513
514    #[test]
515    fn member_identity_resolves_binding_to_the_write_gate_spelling() {
516        // Identity-first internal member: the binding carries the ENCODED
517        // generated runtime alias; the write gate keys on the durable
518        // identity, so the alias must normalize to it.
519        let mut req = CreateSessionRequest {
520            model: "gpt-5.5".to_string(),
521            prompt: meerkat_core::ContentInput::Text("hi".to_string()),
522            injected_context: Vec::new(),
523            system_prompt: meerkat_core::config::SystemPromptOverride::Inherit,
524            max_tokens: None,
525            event_tx: None,
526            initial_turn: meerkat_core::service::InitialTurnPolicy::Defer,
527            deferred_prompt_policy: meerkat_core::service::DeferredPromptPolicy::default(),
528            build: Some(SessionBuildOptions {
529                mob_member_binding: Some(meerkat_core::MobMemberBinding {
530                    mob_id: "mob-1".to_string(),
531                    role: "worker".to_string(),
532                    member: member_comms_id::mob_member_id_str("rt:review:singleton:0")
533                        .into_owned(),
534                }),
535                ..SessionBuildOptions::default()
536            }),
537            labels: None,
538        };
539        assert_eq!(
540            member_taint_identity(&req).as_deref(),
541            Some("review:singleton"),
542            "rt:{{identity}}:{{generation}} normalizes to the durable identity"
543        );
544
545        // Classic member: the decoded binding alias IS the recorder's key.
546        let binding = req
547            .build
548            .as_mut()
549            .and_then(|build| build.mob_member_binding.as_mut())
550            .expect("binding");
551        binding.member = "helper".to_string();
552        assert_eq!(member_taint_identity(&req).as_deref(), Some("helper"));
553
554        // Identity-first external binding: the roster id encodes the durable
555        // identity directly (no rt: shape to strip).
556        let binding = req
557            .build
558            .as_mut()
559            .and_then(|build| build.mob_member_binding.as_mut())
560            .expect("binding");
561        binding.member = member_comms_id::mob_member_id_str("review:singleton").into_owned();
562        assert_eq!(
563            member_taint_identity(&req).as_deref(),
564            Some("review:singleton")
565        );
566
567        // A non-numeric trailing segment is not a generation: the alias is
568        // some other rt:-prefixed name and stays whole (conservative).
569        let binding = req
570            .build
571            .as_mut()
572            .and_then(|build| build.mob_member_binding.as_mut())
573            .expect("binding");
574        binding.member = member_comms_id::mob_member_id_str("rt:oddly:named").into_owned();
575        assert_eq!(
576            member_taint_identity(&req).as_deref(),
577            Some("rt:oddly:named")
578        );
579
580        // No binding: not a member build, no decorator.
581        req.build = None;
582        assert_eq!(member_taint_identity(&req), None);
583        let mut req = req;
584        attach_member_taint_decorator(&mut req, &DispatchTaintSlot::default());
585        assert!(req.build.is_none(), "non-member requests stay untouched");
586    }
587}