Skip to main content

zeph_sanitizer/
types.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Core types for the sanitization pipeline: trust model, content provenance, and results.
5
6use serde::{Deserialize, Serialize};
7
8// ---------------------------------------------------------------------------
9// Trust model
10// ---------------------------------------------------------------------------
11
12/// Trust tier assigned to content entering the agent context.
13///
14/// Drives spotlighting intensity: [`Trusted`](ContentTrustLevel::Trusted) content passes
15/// through unchanged; [`ExternalUntrusted`](ContentTrustLevel::ExternalUntrusted) receives
16/// the strongest warning header.
17///
18/// The tier is typically derived automatically from [`ContentSourceKind::default_trust_level`],
19/// but can be overridden via [`ContentSource::with_trust_level`] when the call-site has
20/// more context about the actual origin of the content.
21///
22/// # Examples
23///
24/// ```rust
25/// use zeph_sanitizer::{ContentTrustLevel, ContentSource, ContentSourceKind};
26///
27/// // Web scrapes default to the strongest warning level.
28/// let source = ContentSource::new(ContentSourceKind::WebScrape);
29/// assert_eq!(source.trust_level, ContentTrustLevel::ExternalUntrusted);
30///
31/// // Trust level can be overridden.
32/// let elevated = source.with_trust_level(ContentTrustLevel::Trusted);
33/// assert_eq!(elevated.trust_level, ContentTrustLevel::Trusted);
34/// ```
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37#[non_exhaustive]
38pub enum ContentTrustLevel {
39    /// System prompt, hardcoded instructions, direct user input. No wrapping applied.
40    Trusted,
41    /// Tool results from local executors (shell, file I/O). Lighter warning.
42    LocalUntrusted,
43    /// External sources: web scrape, MCP, A2A, memory retrieval. Strongest warning.
44    ExternalUntrusted,
45}
46
47/// All known content source categories.
48///
49/// Used for spotlighting annotation and future per-source config overrides.
50/// Each variant maps to a fixed [`ContentTrustLevel`] via [`default_trust_level`](Self::default_trust_level).
51///
52/// # Examples
53///
54/// ```rust
55/// use zeph_sanitizer::{ContentSourceKind, ContentTrustLevel};
56///
57/// assert_eq!(
58///     ContentSourceKind::ToolResult.default_trust_level(),
59///     ContentTrustLevel::LocalUntrusted
60/// );
61/// assert_eq!(
62///     ContentSourceKind::WebScrape.default_trust_level(),
63///     ContentTrustLevel::ExternalUntrusted
64/// );
65/// ```
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum ContentSourceKind {
70    /// Output from a locally-executed tool (shell, file I/O).
71    ToolResult,
72    /// Content fetched from a remote URL by the web-scrape tool.
73    WebScrape,
74    /// Response from an MCP (Model Context Protocol) server.
75    McpResponse,
76    /// Message received from another agent via the A2A protocol.
77    A2aMessage,
78    /// Content retrieved from Qdrant/SQLite semantic memory.
79    ///
80    /// Memory poisoning is a documented attack vector: an adversary can plant injection
81    /// payloads in web content that gets stored, then recalled in future sessions.
82    MemoryRetrieval,
83    /// Project-level instruction files (`.zeph/zeph.md`, CLAUDE.md, etc.).
84    ///
85    /// Treated as `LocalUntrusted` by default. Path-based trust inference (e.g. treating
86    /// user-authored files as `Trusted`) is a Phase 2 concern.
87    InstructionFile,
88    /// Primary message ingested from an external channel adapter (gateway webhook,
89    /// and potentially Telegram/Discord in the future).
90    ///
91    /// The sender only proves possession of a bearer token or channel credential, not
92    /// that the message content is safe — treated as `ExternalUntrusted` like any other
93    /// network-supplied text.
94    ChannelMessage,
95}
96
97impl ContentSourceKind {
98    /// Returns the default [`ContentTrustLevel`] for this source kind.
99    ///
100    /// Tool results and instruction files are `LocalUntrusted`; all network-sourced
101    /// content (web scrape, MCP, A2A, memory retrieval) is `ExternalUntrusted`.
102    ///
103    /// # Examples
104    ///
105    /// ```rust
106    /// use zeph_sanitizer::{ContentSourceKind, ContentTrustLevel};
107    ///
108    /// assert_eq!(ContentSourceKind::McpResponse.default_trust_level(), ContentTrustLevel::ExternalUntrusted);
109    /// assert_eq!(ContentSourceKind::InstructionFile.default_trust_level(), ContentTrustLevel::LocalUntrusted);
110    /// ```
111    #[must_use]
112    pub fn default_trust_level(self) -> ContentTrustLevel {
113        match self {
114            Self::ToolResult | Self::InstructionFile => ContentTrustLevel::LocalUntrusted,
115            Self::WebScrape
116            | Self::McpResponse
117            | Self::A2aMessage
118            | Self::MemoryRetrieval
119            | Self::ChannelMessage => ContentTrustLevel::ExternalUntrusted,
120        }
121    }
122
123    pub(crate) fn as_str(self) -> &'static str {
124        match self {
125            Self::ToolResult => "tool_result",
126            Self::WebScrape => "web_scrape",
127            Self::McpResponse => "mcp_response",
128            Self::A2aMessage => "a2a_message",
129            Self::MemoryRetrieval => "memory_retrieval",
130            Self::InstructionFile => "instruction_file",
131            Self::ChannelMessage => "channel_message",
132        }
133    }
134
135    /// Parse a `&str` into a [`ContentSourceKind`].
136    ///
137    /// Returns `None` for unrecognized strings so callers can log a warning and
138    /// skip unknown values without breaking deserialization.
139    ///
140    /// The comparison is case-sensitive and uses the canonical `snake_case` form
141    /// (e.g. `"web_scrape"`, not `"WebScrape"`).
142    ///
143    /// # Examples
144    ///
145    /// ```rust
146    /// use zeph_sanitizer::ContentSourceKind;
147    ///
148    /// assert_eq!(ContentSourceKind::from_str_opt("web_scrape"), Some(ContentSourceKind::WebScrape));
149    /// assert_eq!(ContentSourceKind::from_str_opt("WebScrape"), None); // case-sensitive
150    /// assert_eq!(ContentSourceKind::from_str_opt("unknown"), None);
151    /// ```
152    #[must_use]
153    pub fn from_str_opt(s: &str) -> Option<Self> {
154        match s {
155            "tool_result" => Some(Self::ToolResult),
156            "web_scrape" => Some(Self::WebScrape),
157            "mcp_response" => Some(Self::McpResponse),
158            "a2a_message" => Some(Self::A2aMessage),
159            "memory_retrieval" => Some(Self::MemoryRetrieval),
160            "instruction_file" => Some(Self::InstructionFile),
161            "channel_message" => Some(Self::ChannelMessage),
162            _ => None,
163        }
164    }
165}
166
167/// Hint about the origin of memory-retrieved content.
168///
169/// Used to modulate injection detection sensitivity within `ContentSanitizer::sanitize`].
170/// The hint is set at call-site (compile-time) based on which retrieval path produced the
171/// content — it cannot be influenced by the content itself and thus cannot be spoofed.
172///
173/// # Defense-in-depth invariant
174///
175/// Setting a hint to [`ConversationHistory`](MemorySourceHint::ConversationHistory) or
176/// [`LlmSummary`](MemorySourceHint::LlmSummary) **only** skips injection pattern detection
177/// (step 3). Truncation, control-character stripping, delimiter escaping, and spotlighting
178/// remain active for all sources regardless of this hint.
179///
180/// # Known limitation: indirect memory poisoning
181///
182/// Conversation history is treated as first-party (user-typed) content. However, the LLM
183/// may call `memory_save` with content derived from a prior injection in external sources
184/// (web scrape → spotlighted → LLM stores payload → recalled as `[assistant]` turn).
185/// Mitigate by configuring `forbidden_content_patterns` in `[memory.validation]` to block
186/// known injection strings on the write path. This risk is pre-existing and is not worsened
187/// by the hint mechanism.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189#[non_exhaustive]
190pub enum MemorySourceHint {
191    /// Prior user/assistant conversation turns (semantic recall, corrections).
192    ///
193    /// Injection patterns in recalled user text are expected false positives — the user
194    /// legitimately discussed topics like "system prompt" or "show your instructions".
195    ConversationHistory,
196    /// LLM-generated summaries (session summaries, cross-session context).
197    ///
198    /// Low risk: generated by the agent's own model from already-sanitized content.
199    LlmSummary,
200    /// External document chunks or graph entity facts.
201    ///
202    /// Full detection applies — may contain adversarial content from web scrapes,
203    /// MCP responses, or other untrusted sources that were stored in the corpus.
204    ExternalContent,
205}
206
207/// Provenance metadata attached to a piece of untrusted content.
208///
209/// Created at the call-site (tool executor, MCP adapter, A2A handler, etc.) to describe
210/// where content came from. Passed into `ContentSanitizer::sanitize`] alongside the raw
211/// content so the pipeline can choose the appropriate spotlight wrapper and injection
212/// detection sensitivity.
213///
214/// # Examples
215///
216/// ```rust
217/// use zeph_sanitizer::{ContentSource, ContentSourceKind, ContentTrustLevel, MemorySourceHint};
218///
219/// // Basic source for a shell tool result.
220/// let source = ContentSource::new(ContentSourceKind::ToolResult)
221///     .with_identifier("shell");
222/// assert_eq!(source.trust_level, ContentTrustLevel::LocalUntrusted);
223/// assert_eq!(source.identifier.as_deref(), Some("shell"));
224///
225/// // Memory retrieval with a hint to skip injection detection for conversation turns.
226/// let mem_source = ContentSource::new(ContentSourceKind::MemoryRetrieval)
227///     .with_memory_hint(MemorySourceHint::ConversationHistory);
228/// assert!(mem_source.memory_hint.is_some());
229/// ```
230#[derive(Debug, Clone)]
231pub struct ContentSource {
232    /// The category of this content source.
233    pub kind: ContentSourceKind,
234    /// Trust tier that drives the spotlight wrapper choice.
235    pub trust_level: ContentTrustLevel,
236    /// Optional identifier: tool name, URL, agent ID, etc. Used in spotlight attributes.
237    pub identifier: Option<String>,
238    /// Optional hint for memory retrieval sub-sources. When `Some`, modulates injection
239    /// detection sensitivity in `ContentSanitizer::sanitize`]. Non-memory sources leave
240    /// this as `None` — full detection applies.
241    pub memory_hint: Option<MemorySourceHint>,
242}
243
244impl ContentSource {
245    /// Create a new source with the default trust level for the given kind.
246    ///
247    /// # Examples
248    ///
249    /// ```rust
250    /// use zeph_sanitizer::{ContentSource, ContentSourceKind, ContentTrustLevel};
251    ///
252    /// let source = ContentSource::new(ContentSourceKind::WebScrape);
253    /// assert_eq!(source.trust_level, ContentTrustLevel::ExternalUntrusted);
254    /// assert!(source.identifier.is_none());
255    /// ```
256    #[must_use]
257    pub fn new(kind: ContentSourceKind) -> Self {
258        Self {
259            trust_level: kind.default_trust_level(),
260            kind,
261            identifier: None,
262            memory_hint: None,
263        }
264    }
265
266    /// Set the identifier for this source (tool name, URL, agent ID, etc.).
267    ///
268    /// The identifier appears in the spotlight wrapper's XML attributes so the LLM can
269    /// see where the content came from (e.g. `name="shell"`, `ref="https://example.com"`).
270    ///
271    /// # Examples
272    ///
273    /// ```rust
274    /// use zeph_sanitizer::{ContentSource, ContentSourceKind};
275    ///
276    /// let source = ContentSource::new(ContentSourceKind::ToolResult)
277    ///     .with_identifier("shell");
278    /// assert_eq!(source.identifier.as_deref(), Some("shell"));
279    /// ```
280    #[must_use]
281    pub fn with_identifier(mut self, id: impl Into<String>) -> Self {
282        self.identifier = Some(id.into());
283        self
284    }
285
286    /// Override the trust level for this source.
287    ///
288    /// Use when the call-site has more context about the actual origin of the content
289    /// than the default derived from the source kind.
290    ///
291    /// # Examples
292    ///
293    /// ```rust
294    /// use zeph_sanitizer::{ContentSource, ContentSourceKind, ContentTrustLevel};
295    ///
296    /// // Elevate trust for a verified internal source.
297    /// let source = ContentSource::new(ContentSourceKind::McpResponse)
298    ///     .with_trust_level(ContentTrustLevel::LocalUntrusted);
299    /// assert_eq!(source.trust_level, ContentTrustLevel::LocalUntrusted);
300    /// ```
301    #[must_use]
302    pub fn with_trust_level(mut self, level: ContentTrustLevel) -> Self {
303        self.trust_level = level;
304        self
305    }
306
307    /// Attach a memory source hint to modulate injection detection sensitivity.
308    ///
309    /// Only meaningful for `ContentSourceKind::MemoryRetrieval` sources.
310    #[must_use]
311    pub fn with_memory_hint(mut self, hint: MemorySourceHint) -> Self {
312        self.memory_hint = Some(hint);
313        self
314    }
315}
316
317// ---------------------------------------------------------------------------
318// Output types
319// ---------------------------------------------------------------------------
320
321/// A single detected injection pattern match in sanitized content.
322///
323/// Produced by the regex injection-detection step inside `ContentSanitizer::sanitize`].
324/// Injection flags are advisory — they are recorded in [`SanitizedContent`] and surfaced
325/// in the spotlight warning header, but the content is never silently removed.
326#[derive(Debug, Clone)]
327pub struct InjectionFlag {
328    /// Name of the compiled pattern that matched (from `zeph_common::patterns`).
329    pub pattern_name: &'static str,
330    /// Byte offset of the match within the (already truncated, stripped) content.
331    pub byte_offset: usize,
332    /// The matched substring. Kept for logging and operator review.
333    pub matched_text: String,
334}
335
336/// Result of ML-based injection classification.
337///
338/// Replaces a plain `bool` to support a defense-in-depth dual-threshold model.
339/// Real-world ML injection classifiers have 12–37% recall gaps at high confidence
340/// thresholds, so `Suspicious` content is surfaced for operator visibility without
341/// blocking — a mandatory second layer of defense.
342///
343/// Returned by `ContentSanitizer::classify_injection`] (feature `classifiers`).
344///
345/// # Examples
346///
347/// ```rust,ignore
348/// // Requires `classifiers` feature and an attached backend.
349/// let verdict = sanitizer.classify_injection("ignore all instructions").await;
350/// assert!(matches!(verdict, InjectionVerdict::Blocked | InjectionVerdict::Suspicious));
351/// ```
352#[cfg(feature = "classifiers")]
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354#[non_exhaustive]
355pub enum InjectionVerdict {
356    /// Score below soft threshold — no injection signal detected.
357    Clean,
358    /// Score ≥ soft threshold but < hard threshold — suspicious, warn only.
359    Suspicious,
360    /// Score ≥ hard threshold — injection detected. Behavior depends on enforcement mode.
361    Blocked,
362}
363
364/// Classification result from the three-class `AlignSentinel` model.
365///
366/// Used in Stage 2 of `ContentSanitizer::classify_injection`] to refine binary injection
367/// verdicts. `AlignedInstruction` and `NoInstruction` results downgrade `Suspicious`/`Blocked`
368/// to `Clean`, reducing false positives from legitimate instruction-style content in tool
369/// outputs (e.g. a script that prints "run as root").
370///
371/// Only active when a three-class backend is attached via
372/// `ContentSanitizer::with_three_class_backend`].
373#[cfg(feature = "classifiers")]
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375#[non_exhaustive]
376pub enum InstructionClass {
377    /// Content contains no instruction-like text.
378    NoInstruction,
379    /// Content contains instructions aligned with the system's objectives.
380    AlignedInstruction,
381    /// Content contains instructions that conflict with the system's objectives.
382    MisalignedInstruction,
383    /// Model returned an unknown label. Treated conservatively — verdict is NOT downgraded.
384    Unknown,
385}
386
387#[cfg(feature = "classifiers")]
388impl InstructionClass {
389    pub(crate) fn from_label(label: &str) -> Self {
390        match label.to_lowercase().as_str() {
391            "no_instruction" | "no-instruction" | "none" => Self::NoInstruction,
392            "aligned_instruction" | "aligned-instruction" | "aligned" => Self::AlignedInstruction,
393            "misaligned_instruction" | "misaligned-instruction" | "misaligned" => {
394                Self::MisalignedInstruction
395            }
396            _ => Self::Unknown,
397        }
398    }
399}
400
401/// Result of the sanitization pipeline for a single piece of content.
402///
403/// The `body` field is the processed text ready to insert into the agent's message history.
404/// Callers should inspect `injection_flags` for threat intelligence and `was_truncated` to
405/// decide whether to emit a "content was truncated" notice to the user.
406///
407/// # Examples
408///
409/// ```rust
410/// use zeph_sanitizer::{ContentSanitizer, ContentSource, ContentSourceKind};
411/// use zeph_config::ContentIsolationConfig;
412///
413/// let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
414/// let result = sanitizer.sanitize(
415///     "normal tool output",
416///     ContentSource::new(ContentSourceKind::ToolResult),
417/// );
418/// assert!(!result.was_truncated);
419/// assert!(result.injection_flags.is_empty());
420/// assert!(result.body.contains("normal tool output"));
421/// ```
422#[derive(Debug, Clone)]
423pub struct SanitizedContent {
424    /// The processed, possibly spotlighted body ready to insert into message history.
425    pub body: String,
426    /// Provenance metadata for this content.
427    pub source: ContentSource,
428    /// Injection patterns matched during detection (advisory — content is never removed).
429    pub injection_flags: Vec<InjectionFlag>,
430    /// `true` when content was truncated to `max_content_size`.
431    pub was_truncated: bool,
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    // --- ContentSourceKind::from_str_opt roundtrip ---
439
440    #[test]
441    fn from_str_opt_known_variants_roundtrip() {
442        let variants = [
443            (ContentSourceKind::ToolResult, "tool_result"),
444            (ContentSourceKind::WebScrape, "web_scrape"),
445            (ContentSourceKind::McpResponse, "mcp_response"),
446            (ContentSourceKind::A2aMessage, "a2a_message"),
447            (ContentSourceKind::MemoryRetrieval, "memory_retrieval"),
448            (ContentSourceKind::InstructionFile, "instruction_file"),
449            (ContentSourceKind::ChannelMessage, "channel_message"),
450        ];
451        for (kind, s) in &variants {
452            assert_eq!(ContentSourceKind::from_str_opt(s), Some(*kind));
453            assert_eq!(kind.as_str(), *s);
454        }
455    }
456
457    #[test]
458    fn from_str_opt_unknown_returns_none() {
459        assert_eq!(ContentSourceKind::from_str_opt("unknown"), None);
460        assert_eq!(ContentSourceKind::from_str_opt(""), None);
461    }
462
463    #[test]
464    fn from_str_opt_case_sensitive() {
465        assert_eq!(ContentSourceKind::from_str_opt("WebScrape"), None);
466        assert_eq!(ContentSourceKind::from_str_opt("TOOL_RESULT"), None);
467    }
468
469    // --- ContentSource builder methods ---
470
471    #[test]
472    fn content_source_new_has_default_trust_and_no_identifier() {
473        let source = ContentSource::new(ContentSourceKind::WebScrape);
474        assert_eq!(source.trust_level, ContentTrustLevel::ExternalUntrusted);
475        assert!(source.identifier.is_none());
476        assert!(source.memory_hint.is_none());
477    }
478
479    #[test]
480    fn content_source_with_identifier() {
481        let source = ContentSource::new(ContentSourceKind::ToolResult).with_identifier("shell");
482        assert_eq!(source.identifier.as_deref(), Some("shell"));
483    }
484
485    #[test]
486    fn content_source_with_trust_level_override() {
487        let source = ContentSource::new(ContentSourceKind::McpResponse)
488            .with_trust_level(ContentTrustLevel::LocalUntrusted);
489        assert_eq!(source.trust_level, ContentTrustLevel::LocalUntrusted);
490    }
491
492    #[test]
493    fn content_source_with_memory_hint() {
494        let source = ContentSource::new(ContentSourceKind::MemoryRetrieval)
495            .with_memory_hint(MemorySourceHint::ConversationHistory);
496        assert_eq!(
497            source.memory_hint,
498            Some(MemorySourceHint::ConversationHistory)
499        );
500    }
501
502    // --- ContentTrustLevel ---
503
504    #[test]
505    fn content_trust_level_equality() {
506        assert_eq!(ContentTrustLevel::Trusted, ContentTrustLevel::Trusted);
507        assert_ne!(
508            ContentTrustLevel::Trusted,
509            ContentTrustLevel::LocalUntrusted
510        );
511        assert_ne!(
512            ContentTrustLevel::LocalUntrusted,
513            ContentTrustLevel::ExternalUntrusted
514        );
515    }
516
517    // --- default_trust_level mapping ---
518
519    #[test]
520    fn default_trust_level_local_kinds() {
521        assert_eq!(
522            ContentSourceKind::ToolResult.default_trust_level(),
523            ContentTrustLevel::LocalUntrusted
524        );
525        assert_eq!(
526            ContentSourceKind::InstructionFile.default_trust_level(),
527            ContentTrustLevel::LocalUntrusted
528        );
529    }
530
531    #[test]
532    fn default_trust_level_external_kinds() {
533        for kind in [
534            ContentSourceKind::WebScrape,
535            ContentSourceKind::McpResponse,
536            ContentSourceKind::A2aMessage,
537            ContentSourceKind::MemoryRetrieval,
538            ContentSourceKind::ChannelMessage,
539        ] {
540            assert_eq!(
541                kind.default_trust_level(),
542                ContentTrustLevel::ExternalUntrusted
543            );
544        }
545    }
546}