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}