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