Skip to main content

zeph_llm/
masking.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Structural outbound-message masking at the provider boundary (#5437).
5//!
6//! [`MaskedProvider`] wraps any [`crate::any::AnyProvider`] so every outbound `chat*` call
7//! masks message text via an injected [`OutboundMasker`] before the request leaves the
8//! process. Wiring it once, at the point an `AnyProvider` is constructed
9//! (`zeph_core::provider_factory::build_provider_from_entry`), covers every current and future
10//! `chat*` **call site** that dispatches through an already-wrapped provider handle — no
11//! per-call-site enumeration is required there.
12//!
13//! This does *not* by itself make an unmasked provider **assignment** impossible: `self.provider`
14//! (and the other provider-typed `Agent` fields) can still be reassigned later — e.g. a runtime
15//! provider switch — through a path that constructs a fresh, unwrapped `AnyProvider` and skips
16//! wrapping. Two rounds of this fix each missed one such reassignment site (the ACP
17//! `set_session_config_option` provider override was the last one found). Closing that class of
18//! gap for good requires a second, independent guard at the *assignment* boundary — see
19//! `zeph_core::agent::Agent::set_provider`, the single method every `self.provider` reassignment
20//! after construction must go through, which re-wraps on every swap and `debug_assert`s the
21//! invariant so a future bypass fails loudly in tests instead of silently shipping unmasked.
22//!
23//! `zeph-llm` cannot depend on `zeph-sanitizer` (which owns the concrete secret registry and
24//! itself depends on `zeph-llm`), so the masking capability is expressed here as a minimal,
25//! sanitizer-agnostic trait ([`OutboundMasker`]) that a higher-level crate implements as a thin
26//! adapter over its concrete registry.
27
28use std::fmt;
29use std::sync::Arc;
30
31use crate::LlmError;
32use crate::any::AnyProvider;
33use crate::provider::{
34    ChatExtras, ChatResponse, ChatStream, LlmProvider, Message, MessagePart, ToolDefinition,
35};
36use crate::provider_dyn::LlmProviderDyn;
37
38/// Capability for masking outbound message text before it reaches a provider's wire format.
39///
40/// Implemented by an adapter in a higher-level crate (`zeph-core`, which owns the concrete
41/// secret registry) and injected into an [`AnyProvider`] via [`AnyProvider::masked`].
42pub trait OutboundMasker: fmt::Debug + Send + Sync {
43    /// Return a masked copy of `text` when it contains anything that should be masked, or
44    /// `None` when `text` is unchanged. Implementors should back this with a cheap,
45    /// allocation-free "does anything match" pre-check so the common case (no secret present)
46    /// costs nothing beyond that check.
47    fn mask(&self, text: &str) -> Option<String>;
48}
49
50/// Wraps an [`AnyProvider`] so every outbound `chat*`/`chat_with_tools*` call masks message
51/// text via an [`OutboundMasker`] before delegating to the inner provider.
52///
53/// # Examples
54///
55/// ```rust
56/// use std::sync::Arc;
57/// use zeph_llm::any::AnyProvider;
58/// use zeph_llm::masking::OutboundMasker;
59/// use zeph_llm::ollama::OllamaProvider;
60///
61/// #[derive(Debug)]
62/// struct UppercaseMasker;
63/// impl OutboundMasker for UppercaseMasker {
64///     fn mask(&self, text: &str) -> Option<String> {
65///         if text.contains("secret") { Some(text.replace("secret", "***")) } else { None }
66///     }
67/// }
68///
69/// let inner = AnyProvider::Ollama(OllamaProvider::new("http://localhost:11434", "m".into(), "e".into()));
70/// let masked = inner.masked(Arc::new(UppercaseMasker));
71/// assert_eq!(masked.name(), "ollama"); // delegation still works transparently
72/// # use zeph_llm::provider::LlmProvider;
73/// ```
74#[derive(Clone)]
75pub struct MaskedProvider {
76    pub(crate) inner: Box<AnyProvider>,
77    pub(crate) masker: Arc<dyn OutboundMasker>,
78    /// Shared across every clone of this wrapper (same `Arc`) so the count reflects total
79    /// masking activity for the logical session provider, not just one clone's calls.
80    applied_count: Arc<std::sync::atomic::AtomicU64>,
81}
82
83impl fmt::Debug for MaskedProvider {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        f.debug_struct("MaskedProvider")
86            .field("inner", &self.inner)
87            .finish_non_exhaustive()
88    }
89}
90
91impl MaskedProvider {
92    /// Wrap `inner` with `masker`.
93    #[must_use]
94    pub fn new(inner: AnyProvider, masker: Arc<dyn OutboundMasker>) -> Self {
95        Self {
96            inner: Box::new(inner),
97            masker,
98            applied_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
99        }
100    }
101
102    /// Return the wrapped provider, discarding the masking layer.
103    #[must_use]
104    pub fn inner(&self) -> &AnyProvider {
105        &self.inner
106    }
107
108    /// Number of outbound calls (across every clone of this wrapper) that had at least one
109    /// secret masked. Exposed for the `secret_mask_applied` observability metric.
110    #[must_use]
111    pub fn applied_count(&self) -> u64 {
112        self.applied_count
113            .load(std::sync::atomic::Ordering::Relaxed)
114    }
115
116    /// Build a masked copy of `messages` and record it in [`Self::applied_count`], or `None`
117    /// when nothing needed masking. See [`mask_messages`] for the masking rules.
118    pub(crate) fn mask_messages(&self, messages: &[Message]) -> Option<Vec<Message>> {
119        let result = mask_messages(self.masker.as_ref(), messages);
120        if result.is_some() {
121            self.applied_count
122                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
123        }
124        result
125    }
126}
127
128/// Build a masked copy of `messages` against `masker`, or `None` when nothing needed masking.
129///
130/// Masks every text-bearing [`MessagePart`] variant that can carry model-visible content
131/// derived from tool output or conversation history — including `ToolOutput.body` and
132/// `ToolResult.content` (a gap in the crate's own [`MessagePart::as_plain_text`] helper, which
133/// only covers `Text`/`Recall`/`CodeContext`/`Summary`/`CrossSession`) — while never touching
134/// `ThinkingBlock`/`RedactedThinkingBlock` (mutating these would invalidate the provider's
135/// signature verification) or `ToolUse.input`/`Image` (structural, not model-visible free text).
136///
137/// This is the same masking pass [`MaskedProvider`] applies to every outbound `chat*` call.
138/// Exposed standalone for callers outside the provider dispatch path that still need a masked
139/// view of a message slice for local purposes — e.g. debug-dump serialization, which writes a
140/// human-readable JSON representation of the conversation independent of whatever wire format
141/// the provider's own `debug_request_json` produces.
142///
143/// Runs a non-cloning pre-scan over borrowed text first (calling [`OutboundMasker::mask`] on
144/// each candidate field without touching `messages`) and returns `None` immediately if nothing
145/// matches, so the common case — masking enabled but this particular slice has no registered
146/// secret in it — never clones a single [`Message`]. Every agent-loop call site (Await
147/// Discipline, `.claude/rules/rust-code.md`) runs this on every outbound dispatch, so the
148/// no-match path must stay allocation-free for `messages` itself; only the (rare) match path
149/// pays for `Message`/`MessagePart` clones.
150#[must_use]
151pub fn mask_messages(masker: &dyn OutboundMasker, messages: &[Message]) -> Option<Vec<Message>> {
152    let any_candidate = messages.iter().any(|m| {
153        if m.parts.is_empty() {
154            masker.mask(&m.content).is_some()
155        } else {
156            m.parts
157                .iter()
158                .filter_map(part_text_ref)
159                .any(|text| masker.mask(text).is_some())
160        }
161    });
162    if !any_candidate {
163        return None;
164    }
165
166    let mut any_masked = false;
167    let result: Vec<Message> = messages
168        .iter()
169        .map(|original| {
170            let mut msg = original.clone();
171            if msg.parts.is_empty() {
172                if let Some(masked_content) = masker.mask(&msg.content) {
173                    any_masked = true;
174                    msg.content = masked_content;
175                }
176            } else {
177                let mut changed = false;
178                for part in &mut msg.parts {
179                    if let Some(text) = part_text_mut(part)
180                        && let Some(masked_text) = masker.mask(text)
181                    {
182                        *text = masked_text;
183                        changed = true;
184                    }
185                }
186                if changed {
187                    any_masked = true;
188                    msg.rebuild_content();
189                }
190            }
191            msg
192        })
193        .collect();
194    any_masked.then_some(result)
195}
196
197/// Return the mutable text field of a text-bearing [`MessagePart`] variant, `None` for
198/// variants that carry no maskable free text: `ToolUse.input` (structural JSON the model
199/// produced, never a raw secret), `Image`, and `ThinkingBlock`/`RedactedThinkingBlock` (must
200/// never be mutated — doing so invalidates the provider's signature verification).
201fn part_text_mut(part: &mut MessagePart) -> Option<&mut String> {
202    match part {
203        MessagePart::Text { text }
204        | MessagePart::Recall { text }
205        | MessagePart::CodeContext { text }
206        | MessagePart::Summary { text }
207        | MessagePart::CrossSession { text } => Some(text),
208        MessagePart::ToolOutput { body, .. } => Some(body),
209        MessagePart::ToolResult { content, .. } => Some(content),
210        MessagePart::Compaction { summary } => Some(summary),
211        _ => None,
212    }
213}
214
215/// Immutable-reference counterpart of [`part_text_mut`], used for the non-cloning pre-scan.
216fn part_text_ref(part: &MessagePart) -> Option<&str> {
217    match part {
218        MessagePart::Text { text }
219        | MessagePart::Recall { text }
220        | MessagePart::CodeContext { text }
221        | MessagePart::Summary { text }
222        | MessagePart::CrossSession { text } => Some(text.as_str()),
223        MessagePart::ToolOutput { body, .. } => Some(body.as_str()),
224        MessagePart::ToolResult { content, .. } => Some(content.as_str()),
225        MessagePart::Compaction { summary } => Some(summary.as_str()),
226        _ => None,
227    }
228}
229
230impl LlmProvider for MaskedProvider {
231    fn context_window(&self) -> Option<usize> {
232        LlmProvider::context_window(self.inner.as_ref())
233    }
234
235    // The recursive calls below go through `LlmProviderDyn` (concrete `BoxFuture` return),
236    // not `LlmProvider` (opaque `impl Future` return) — `AnyProvider::chat` delegates to
237    // `MaskedProvider::chat` for its `Masked` variant, so calling back into
238    // `LlmProvider::chat` here would make the two native async-fn-in-trait impls'
239    // opaque return types mutually depend on each other, which rustc cannot resolve
240    // (E0391 cycle). `LlmProviderDyn`'s named `BoxFuture` type breaks the cycle.
241
242    async fn chat(&self, messages: &[Message]) -> Result<String, LlmError> {
243        let masked = self.mask_messages(messages);
244        LlmProviderDyn::chat(self.inner.as_ref(), masked.as_deref().unwrap_or(messages)).await
245    }
246
247    async fn chat_with_extras(
248        &self,
249        messages: &[Message],
250    ) -> Result<(String, ChatExtras), LlmError> {
251        let masked = self.mask_messages(messages);
252        LlmProviderDyn::chat_with_extras(self.inner.as_ref(), masked.as_deref().unwrap_or(messages))
253            .await
254    }
255
256    async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
257        let masked = self.mask_messages(messages);
258        LlmProviderDyn::chat_stream(self.inner.as_ref(), masked.as_deref().unwrap_or(messages))
259            .await
260    }
261
262    fn supports_streaming(&self) -> bool {
263        LlmProvider::supports_streaming(self.inner.as_ref())
264    }
265
266    async fn embed(&self, text: &str) -> Result<Vec<f32>, LlmError> {
267        // Embeddings feed semantic search/similarity, not model-visible chat context — masking
268        // would corrupt the embedding space for no confidentiality benefit (the embedding
269        // vector itself doesn't reveal the plaintext to a human/log reader the way a chat
270        // transcript does). Not a #5437 concern; pass through unchanged.
271        LlmProviderDyn::embed(self.inner.as_ref(), text).await
272    }
273
274    async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, LlmError> {
275        LlmProviderDyn::embed_batch(self.inner.as_ref(), texts).await
276    }
277
278    fn supports_embeddings(&self) -> bool {
279        LlmProvider::supports_embeddings(self.inner.as_ref())
280    }
281
282    fn name(&self) -> &str {
283        LlmProvider::name(self.inner.as_ref())
284    }
285
286    fn model_identifier(&self) -> &str {
287        LlmProvider::model_identifier(self.inner.as_ref())
288    }
289
290    fn effective_model_identifier(&self) -> &str {
291        LlmProvider::effective_model_identifier(self.inner.as_ref())
292    }
293
294    fn supports_structured_output(&self) -> bool {
295        LlmProvider::supports_structured_output(self.inner.as_ref())
296    }
297
298    fn supports_vision(&self) -> bool {
299        LlmProvider::supports_vision(self.inner.as_ref())
300    }
301
302    fn supports_tool_use(&self) -> bool {
303        LlmProvider::supports_tool_use(self.inner.as_ref())
304    }
305
306    async fn chat_with_tools(
307        &self,
308        messages: &[Message],
309        tools: &[ToolDefinition],
310    ) -> Result<ChatResponse, LlmError> {
311        let masked = self.mask_messages(messages);
312        LlmProviderDyn::chat_with_tools(
313            self.inner.as_ref(),
314            masked.as_deref().unwrap_or(messages),
315            tools,
316        )
317        .await
318    }
319
320    fn last_cache_usage(&self) -> Option<(u64, u64)> {
321        LlmProvider::last_cache_usage(self.inner.as_ref())
322    }
323
324    fn last_usage(&self) -> Option<(u64, u64)> {
325        LlmProvider::last_usage(self.inner.as_ref())
326    }
327
328    fn last_reasoning_tokens(&self) -> Option<u64> {
329        LlmProvider::last_reasoning_tokens(self.inner.as_ref())
330    }
331
332    fn last_ttft_ms(&self) -> Option<u64> {
333        LlmProvider::last_ttft_ms(self.inner.as_ref())
334    }
335
336    fn debug_request_json(
337        &self,
338        messages: &[Message],
339        tools: &[ToolDefinition],
340        stream: bool,
341    ) -> serde_json::Value {
342        let masked = self.mask_messages(messages);
343        LlmProvider::debug_request_json(
344            self.inner.as_ref(),
345            masked.as_deref().unwrap_or(messages),
346            tools,
347            stream,
348        )
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::mock::MockProvider;
356    use crate::provider::Role;
357
358    #[derive(Debug)]
359    struct FixedMasker;
360    impl OutboundMasker for FixedMasker {
361        fn mask(&self, text: &str) -> Option<String> {
362            if text.contains("SECRET_VALUE") {
363                Some(text.replace("SECRET_VALUE", "<MASKED>"))
364            } else {
365                None
366            }
367        }
368    }
369
370    fn masked(inner: AnyProvider) -> MaskedProvider {
371        MaskedProvider::new(inner, Arc::new(FixedMasker))
372    }
373
374    fn mock_any(responses: Vec<String>) -> AnyProvider {
375        AnyProvider::Mock(MockProvider::with_responses(responses))
376    }
377
378    #[tokio::test]
379    async fn chat_masks_flat_content() {
380        let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
381        let mp = masked(AnyProvider::Mock(mock));
382        let messages = vec![Message::from_legacy(
383            Role::User,
384            "value is SECRET_VALUE here",
385        )];
386        LlmProvider::chat(&mp, &messages).await.unwrap();
387        let sent = recorded.lock().unwrap();
388        assert!(!sent[0][0].content.contains("SECRET_VALUE"));
389        assert!(sent[0][0].content.contains("<MASKED>"));
390    }
391
392    #[tokio::test]
393    async fn chat_with_no_match_forwards_unchanged() {
394        let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
395        let mp = masked(AnyProvider::Mock(mock));
396        let messages = vec![Message::from_legacy(Role::User, "nothing sensitive")];
397        LlmProvider::chat(&mp, &messages).await.unwrap();
398        let sent = recorded.lock().unwrap();
399        assert_eq!(sent[0][0].content, "nothing sensitive");
400    }
401
402    /// Counts `mask()` invocations, to pin the non-cloning pre-scan's cost profile: a no-match
403    /// slice must call `mask()` exactly once per text field and then stop (pre-scan only, no
404    /// second clone-and-mask pass); a slice with a match calls `mask()` up to twice per matching
405    /// field (once in the pre-scan, once when actually building the masked copy).
406    #[derive(Debug)]
407    struct CountingMasker {
408        calls: std::sync::atomic::AtomicUsize,
409    }
410    impl CountingMasker {
411        fn new() -> Self {
412            Self {
413                calls: std::sync::atomic::AtomicUsize::new(0),
414            }
415        }
416        fn call_count(&self) -> usize {
417            self.calls.load(std::sync::atomic::Ordering::Relaxed)
418        }
419    }
420    impl OutboundMasker for CountingMasker {
421        fn mask(&self, text: &str) -> Option<String> {
422            self.calls
423                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
424            if text.contains("SECRET_VALUE") {
425                Some(text.replace("SECRET_VALUE", "<MASKED>"))
426            } else {
427                None
428            }
429        }
430    }
431
432    #[test]
433    fn mask_messages_no_match_returns_none_via_pre_scan_only() {
434        let masker = CountingMasker::new();
435        let messages: Vec<Message> = (0..25)
436            .map(|i| Message::from_legacy(Role::User, format!("clean message #{i}")))
437            .collect();
438
439        let result = mask_messages(&masker, &messages);
440
441        assert!(
442            result.is_none(),
443            "no registered secret anywhere — must return None"
444        );
445        assert_eq!(
446            masker.call_count(),
447            25,
448            "no-match path must call mask() exactly once per message (pre-scan only) — a \
449             higher count would mean a second clone-and-mask pass ran despite no match"
450        );
451    }
452
453    #[test]
454    fn mask_messages_finds_match_among_many_clean_messages() {
455        let masker = CountingMasker::new();
456        let mut messages: Vec<Message> = (0..10)
457            .map(|i| Message::from_legacy(Role::User, format!("clean message #{i}")))
458            .collect();
459        messages.push(Message::from_legacy(
460            Role::User,
461            "value is SECRET_VALUE here",
462        ));
463
464        let result = mask_messages(&masker, &messages).expect("one message matches");
465
466        assert_eq!(result.len(), 11);
467        assert!(!result[10].content.contains("SECRET_VALUE"));
468        assert!(result[10].content.contains("<MASKED>"));
469        // Unrelated clean messages are still present and unchanged.
470        assert_eq!(result[0].content, "clean message #0");
471    }
472
473    #[tokio::test]
474    async fn chat_masks_tool_result_content() {
475        let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
476        let mp = masked(AnyProvider::Mock(mock));
477        let messages = vec![Message::from_parts(
478            Role::User,
479            vec![MessagePart::ToolResult {
480                tool_use_id: "id1".into(),
481                content: "tool printed SECRET_VALUE".into(),
482                is_error: false,
483            }],
484        )];
485        LlmProvider::chat(&mp, &messages).await.unwrap();
486        let sent = recorded.lock().unwrap();
487        let MessagePart::ToolResult { content, .. } = &sent[0][0].parts[0] else {
488            panic!("expected ToolResult part");
489        };
490        assert!(!content.contains("SECRET_VALUE"));
491        // flat `content` field must be resynced too.
492        assert!(!sent[0][0].content.contains("SECRET_VALUE"));
493    }
494
495    #[tokio::test]
496    async fn chat_masks_tool_output_body() {
497        let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
498        let mp = masked(AnyProvider::Mock(mock));
499        let messages = vec![Message::from_parts(
500            Role::User,
501            vec![MessagePart::ToolOutput {
502                tool_name: "bash".into(),
503                body: "env dump: SECRET_VALUE".into(),
504                compacted_at: None,
505            }],
506        )];
507        LlmProvider::chat(&mp, &messages).await.unwrap();
508        let sent = recorded.lock().unwrap();
509        let MessagePart::ToolOutput { body, .. } = &sent[0][0].parts[0] else {
510            panic!("expected ToolOutput part");
511        };
512        assert!(!body.contains("SECRET_VALUE"));
513    }
514
515    #[tokio::test]
516    async fn chat_never_touches_thinking_block() {
517        let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
518        let mp = masked(AnyProvider::Mock(mock));
519        let messages = vec![Message::from_parts(
520            Role::Assistant,
521            vec![MessagePart::ThinkingBlock {
522                thinking: "reasoning mentions SECRET_VALUE".into(),
523                signature: "sig123".into(),
524            }],
525        )];
526        LlmProvider::chat(&mp, &messages).await.unwrap();
527        let sent = recorded.lock().unwrap();
528        let MessagePart::ThinkingBlock {
529            thinking,
530            signature,
531        } = &sent[0][0].parts[0]
532        else {
533            panic!("expected ThinkingBlock part");
534        };
535        // Untouched — mutating a signed thinking block would break signature verification.
536        assert!(thinking.contains("SECRET_VALUE"));
537        assert_eq!(signature, "sig123");
538    }
539
540    #[tokio::test]
541    async fn delegation_methods_pass_through_to_inner() {
542        let mp = masked(mock_any(vec![]));
543        assert_eq!(LlmProvider::name(&mp), "mock");
544        assert_eq!(
545            LlmProvider::context_window(&mp),
546            LlmProvider::context_window(mp.inner())
547        );
548    }
549
550    #[test]
551    fn debug_impl_does_not_panic() {
552        let mp = masked(mock_any(vec![]));
553        let s = format!("{mp:?}");
554        assert!(s.contains("MaskedProvider"));
555    }
556
557    /// #6183: a masked Router must still resolve the real dispatched sub-provider's model id,
558    /// not fall through to the trait default (`model_identifier()` -> inner `"router"` label).
559    #[test]
560    fn effective_model_identifier_resolves_through_masked_router() {
561        use crate::claude::ClaudeProvider;
562        use crate::router::RouterProvider;
563
564        let claude = AnyProvider::Claude(ClaudeProvider::new(
565            "k".into(),
566            "claude-3-opus-think".into(),
567            1024,
568        ));
569        let router = RouterProvider::new(vec![claude]);
570        *router.state.last_active_provider.lock() = Some("claude".to_owned());
571
572        let mp = masked(AnyProvider::Router(Box::new(router)));
573
574        assert_eq!(
575            LlmProvider::effective_model_identifier(&mp),
576            "claude-3-opus-think"
577        );
578        assert_ne!(LlmProvider::effective_model_identifier(&mp), "router");
579    }
580}