1use 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#[derive(Clone, Default)]
61pub struct DispatchTaintSlot {
62 inner: Arc<RwLock<Option<SessionTaintTracker>>>,
63}
64
65impl DispatchTaintSlot {
66 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
92fn 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
106pub(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 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
134pub struct TaintObservingLlmClient {
138 inner: Arc<dyn AgentLlmClient>,
139 identity: String,
140 slot: DispatchTaintSlot,
141 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 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 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 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 #[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 #[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 #[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 #[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 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 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 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 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 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 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}