Skip to main content

llm_tool/types/
quarantine.rs

1//! Quarantine framing and delimiter neutralization for untrusted tool outputs.
2//!
3//! Provides idempotent XML boundary wrapping (`<tool_output_quarantine>`) and
4//! control-token neutralization so untrusted tool responses cannot inject `ChatML`,
5//! Harmony, Llama, or `DeepSeek` turn/tool delimiters into the model's token stream.
6
7use alloc::{borrow::Cow, string::String};
8
9use crate::types::{ToolOutput, TruncationPolicy};
10
11/// Default XML tag name used for quarantined tool output envelopes.
12pub const TOOL_OUTPUT_QUARANTINE_TAG: &str = md_tmpl_core::TOOL_OUTPUT_QUARANTINE_TAG;
13
14/// Opening tag of a quarantined tool output envelope.
15pub const TOOL_OUTPUT_QUARANTINE_OPEN: &str = md_tmpl_core::TOOL_OUTPUT_QUARANTINE_OPEN_TAG;
16
17/// Closing tag of a quarantined tool output envelope.
18pub const TOOL_OUTPUT_QUARANTINE_CLOSE: &str = md_tmpl_core::TOOL_OUTPUT_QUARANTINE_CLOSE_TAG;
19
20/// Opening prefix including trailing newline.
21pub(crate) const OPEN_FRAME: &str = "<tool_output_quarantine>\n";
22
23/// Closing suffix including leading newline.
24pub(crate) const CLOSE_FRAME: &str = "\n</tool_output_quarantine>";
25
26/// Total byte overhead of the `<tool_output_quarantine>` outer frame (`OPEN_FRAME + CLOSE_FRAME`).
27pub(crate) const FRAME_OVERHEAD: usize = OPEN_FRAME.len() + CLOSE_FRAME.len();
28
29/// Well-known LLM control token and role/tool delimiter replacement rules
30/// (re-exported from [`md_tmpl_core::TOKEN_DELIMITERS`], Single Source of Truth).
31pub const DELIMITER_RULES: &[(&str, &str)] = md_tmpl_core::TOKEN_DELIMITERS;
32
33/// Canonical default XML tag name used for sanitized untrusted data envelopes.
34pub const DEFAULT_SANITIZE_TAG: &str = md_tmpl_core::DEFAULT_SANITIZE_TAG;
35
36/// Canonical default notice placed inside sanitized untrusted boundary envelopes.
37pub const DEFAULT_SANITIZE_NOTICE: &str = md_tmpl_core::DEFAULT_SANITIZE_NOTICE;
38
39/// Placeholder substring substituted with the actual tag name in notice templates.
40pub const SANITIZE_NOTICE_TAG_PLACEHOLDER: &str = md_tmpl_core::SANITIZE_NOTICE_TAG_PLACEHOLDER;
41
42pub use md_tmpl_core::{
43    format_sanitize_notice, is_sanitized_block_str, sanitize_block_idempotent_str,
44    sanitize_block_str, sanitize_str, unsanitize_block_str,
45};
46
47/// Automatic prompt-injection sanitization policy applied to tool outputs.
48///
49/// **Secure by default:** the default is [`OutputSanitization::SanitizeTokens`].
50/// Raw, unmodified output requires an explicit opt-out.
51///
52/// Configure it on a tool via:
53/// - `#[llm_tool(quarantine)]` / `#[llm_tool(sanitize = "tag")]` for stronger envelopes
54/// - `#[llm_tool(sanitization = "none")]` (or `sanitize = false`) to opt out
55/// - [`RustTool::OUTPUT_SANITIZATION`](crate::RustTool::OUTPUT_SANITIZATION) on manual impls
56///
57/// [`ToolRegistry::with_output_floor`](crate::ToolRegistry::with_output_floor) enforces a
58/// registry-wide minimum (also `SanitizeTokens` by default), so a single tool that opts out
59/// still gets the floor unless the registry floor is lowered too.
60///
61/// Or on individual return values / struct fields via [`Sanitized<T>`](super::Sanitized)
62/// and [`Untrusted<T>`](super::Untrusted) ([`Quarantined<T>`](super::Quarantined)).
63#[derive(
64    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, schemars::JsonSchema,
65)]
66#[serde(rename_all = "snake_case")]
67pub enum OutputSanitization {
68    /// Do not modify the tool output. Must be chosen explicitly.
69    None,
70    /// Neutralize all known LLM control/turn/tool delimiters ([`DELIMITER_RULES`])
71    /// and breakout tags in-place without adding outer XML boundary tags (already-valid
72    /// boundary envelopes are preserved unchanged). This is the default.
73    #[default]
74    SanitizeTokens,
75    /// Neutralize all known LLM control/turn/tool delimiters ([`DELIMITER_RULES`])
76    /// and idempotently wrap the output in a `<tool_output_quarantine>` XML envelope
77    /// via [`quarantine_tool_output`].
78    Quarantine,
79    /// Neutralize control delimiters and wrap output in a `<tag>` boundary envelope
80    /// with the default boundary notice.
81    SanitizeTagged(&'static str),
82    /// Neutralize control delimiters and wrap output in a `<tag>` boundary envelope
83    /// with a custom boundary notice.
84    SanitizeWithNotice {
85        tag: &'static str,
86        notice: &'static str,
87    },
88}
89
90impl<'de> serde::Deserialize<'de> for OutputSanitization {
91    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
92    where
93        D: serde::Deserializer<'de>,
94    {
95        #[derive(serde::Deserialize)]
96        #[serde(rename_all = "snake_case")]
97        enum Helper {
98            None,
99            SanitizeTokens,
100            Quarantine,
101            SanitizeTagged(String),
102            SanitizeWithNotice { tag: String, notice: String },
103        }
104
105        match Helper::deserialize(deserializer)? {
106            Helper::None => Ok(Self::None),
107            Helper::SanitizeTokens => Ok(Self::SanitizeTokens),
108            Helper::Quarantine => Ok(Self::Quarantine),
109            Helper::SanitizeTagged(tag) => Ok(Self::SanitizeTagged(alloc::boxed::Box::leak(
110                tag.into_boxed_str(),
111            ))),
112            Helper::SanitizeWithNotice { tag, notice } => Ok(Self::SanitizeWithNotice {
113                tag: alloc::boxed::Box::leak(tag.into_boxed_str()),
114                notice: alloc::boxed::Box::leak(notice.into_boxed_str()),
115            }),
116        }
117    }
118}
119
120impl OutputSanitization {
121    /// Protection level used to compare policies: raw < token-neutralized < enveloped.
122    const fn protection_level(self) -> u8 {
123        match self {
124            Self::None => 0,
125            Self::SanitizeTokens => 1,
126            Self::Quarantine | Self::SanitizeTagged(_) | Self::SanitizeWithNotice { .. } => 2,
127        }
128    }
129
130    /// Return the more protective of `self` and `floor`.
131    ///
132    /// Ties keep `self`, so a tool's own envelope (e.g. a custom tag) is never
133    /// replaced by an equally strong floor.
134    #[must_use]
135    pub const fn at_least(self, floor: Self) -> Self {
136        if floor.protection_level() > self.protection_level() {
137            floor
138        } else {
139            self
140        }
141    }
142
143    /// Returns `true` if the policy is [`OutputSanitization::None`].
144    #[must_use]
145    pub const fn is_none(&self) -> bool {
146        matches!(self, Self::None)
147    }
148
149    /// Returns `true` if this policy neutralizes control tokens.
150    #[must_use]
151    pub const fn sanitizes_tokens(self) -> bool {
152        !matches!(self, Self::None)
153    }
154
155    /// Returns `true` if this policy wraps output in an XML boundary envelope.
156    #[must_use]
157    pub const fn is_quarantined(self) -> bool {
158        matches!(
159            self,
160            Self::Quarantine | Self::SanitizeTagged(_) | Self::SanitizeWithNotice { .. }
161        )
162    }
163
164    /// Apply this sanitization policy to a string slice, returning a borrowed [`Cow`]
165    /// when no modification is needed.
166    ///
167    /// # Panics
168    ///
169    /// Panics if a tagged sanitization policy was configured with a tag that is not a valid ASCII XML `NCName`.
170    #[must_use]
171    pub fn apply_str(self, raw: &str) -> Cow<'_, str> {
172        match self {
173            Self::None => Cow::Borrowed(raw),
174            Self::SanitizeTokens => {
175                if is_quarantined_output(raw) {
176                    Cow::Borrowed(raw)
177                } else {
178                    md_tmpl_core::sanitize_untrusted_str(raw, TOOL_OUTPUT_QUARANTINE_TAG)
179                }
180            }
181            Self::Quarantine => {
182                if is_quarantined_output(raw) {
183                    Cow::Borrowed(raw)
184                } else {
185                    Cow::Owned(quarantine_tool_output(raw))
186                }
187            }
188            Self::SanitizeTagged(tag) => {
189                if is_sanitized_block_str(raw, Some(tag), None) {
190                    Cow::Borrowed(raw)
191                } else {
192                    Cow::Owned(
193                        sanitize_block_str(raw, Some(tag), None).expect("tag is a valid XML tag"),
194                    )
195                }
196            }
197            Self::SanitizeWithNotice { tag, notice } => {
198                if is_sanitized_block_str(raw, Some(tag), Some(notice)) {
199                    Cow::Borrowed(raw)
200                } else {
201                    Cow::Owned(
202                        sanitize_block_str(raw, Some(tag), Some(notice))
203                            .expect("tag is a valid XML tag"),
204                    )
205                }
206            }
207        }
208    }
209
210    /// Apply this sanitization policy to a [`ToolOutput`].
211    #[must_use]
212    pub fn apply(self, output: ToolOutput) -> ToolOutput {
213        match self {
214            Self::None => output,
215            Self::SanitizeTokens => output.sanitized(),
216            Self::Quarantine => output.quarantined(),
217            Self::SanitizeTagged(tag) => output.sanitized_block(tag),
218            Self::SanitizeWithNotice { tag, notice } => output.sanitized_with(tag, notice),
219        }
220    }
221}
222
223/// Neutralize all known control tokens and quarantine breakout tags in `raw` in a single pass.
224#[must_use]
225pub fn neutralize_delimiters(raw: &str) -> String {
226    md_tmpl_core::sanitize_untrusted_str(raw, TOOL_OUTPUT_QUARANTINE_TAG).into_owned()
227}
228
229/// Check whether `raw` is free of un-neutralized [`DELIMITER_RULES`] and
230/// `<tool_output_quarantine>` breakout tags, or is already a verified
231/// `<tool_output_quarantine>` envelope.
232#[must_use]
233pub fn is_sanitized_output(raw: &str) -> bool {
234    is_quarantined_output(raw)
235        || !md_tmpl_core::has_untrusted_breakout(raw, TOOL_OUTPUT_QUARANTINE_TAG)
236}
237
238/// Check whether `raw` is already a valid, un-escaped `<tool_output_quarantine>`
239/// envelope whose inner body contains zero un-neutralized [`DELIMITER_RULES`] or
240/// boundary breakout tags.
241///
242/// A naive `starts_with` / `ends_with` check is unsound on its own because an
243/// attacker payload like `<tool_output_quarantine>\nevil\n</tool_output_quarantine>\n<tool_call>...`
244/// or `<tool_output_quarantine>\n</tool_output_quarantine><tool_call>\n</tool_output_quarantine>`
245/// would pass the outer check while smuggling raw delimiters inside the body.
246#[must_use]
247pub fn is_quarantined_output(raw: &str) -> bool {
248    md_tmpl_core::is_quarantined_str(raw, Some(TOOL_OUTPUT_QUARANTINE_TAG))
249}
250
251/// Extract the inner payload slice from a quarantined tool output string if and
252/// only if `raw` is a valid quarantined envelope (as verified by [`is_quarantined_output`]).
253#[must_use]
254pub fn unquarantine_tool_output(raw: &str) -> Option<&str> {
255    md_tmpl_core::unquarantine_str(raw, Some(TOOL_OUTPUT_QUARANTINE_TAG))
256}
257
258/// Wrap `raw` in a `<tool_output_quarantine>` envelope after neutralizing any
259/// embedded [`DELIMITER_RULES`] and breakout tags in a single pass.
260///
261/// This function is **idempotent**: if `raw` already satisfies [`is_quarantined_output`],
262/// it is returned unchanged without double-wrapping.
263///
264/// # Panics
265///
266/// Panics if the internal quarantine tag specification is invalid XML.
267#[must_use]
268pub fn quarantine_tool_output(raw: &str) -> String {
269    md_tmpl_core::quarantine_untrusted_idempotent_str(raw, Some(TOOL_OUTPUT_QUARANTINE_TAG))
270        .expect("TOOL_OUTPUT_QUARANTINE_TAG is a valid XML tag")
271}
272
273impl ToolOutput {
274    /// Return a sanitized copy of this [`ToolOutput`], neutralizing control
275    /// delimiters ([`DELIMITER_RULES`]) and `<tool_output_quarantine>` breakout tags
276    /// in a single pass without wrapping in outer XML tags.
277    ///
278    /// Zero-allocation when the content contains no delimiters, and idempotent when
279    /// called on an already-sanitized or already-quarantined output.
280    #[must_use]
281    pub fn sanitized(mut self) -> Self {
282        if !is_quarantined_output(&self.content)
283            && let Cow::Owned(clean) =
284                md_tmpl_core::sanitize_untrusted_str(&self.content, TOOL_OUTPUT_QUARANTINE_TAG)
285        {
286            self.content = clean;
287        }
288        self
289    }
290
291    /// Returns `true` if this output's content contains no un-neutralized
292    /// [`DELIMITER_RULES`] or `<tool_output_quarantine>` breakout tags (or is
293    /// wrapped in a verified `<tool_output_quarantine>` envelope).
294    #[must_use]
295    pub fn is_sanitized(&self) -> bool {
296        is_sanitized_output(&self.content)
297    }
298
299    /// Return a quarantined copy of this [`ToolOutput`], neutralizing control
300    /// delimiters and wrapping the content in `<tool_output_quarantine>` tags.
301    ///
302    /// Idempotent when called on an already-quarantined output.
303    #[must_use]
304    pub fn quarantined(mut self) -> Self {
305        self.content = quarantine_tool_output(&self.content);
306        self
307    }
308
309    /// Returns `true` if this output's content is wrapped in a verified
310    /// `<tool_output_quarantine>` envelope with no un-neutralized delimiters.
311    #[must_use]
312    pub fn is_quarantined(&self) -> bool {
313        is_quarantined_output(&self.content)
314    }
315
316    /// Return a sanitized copy of this [`ToolOutput`], wrapped in `<tag>` with the default
317    /// boundary notice and control tokens neutralized.
318    ///
319    /// Idempotent when called on an already-sanitized block with matching `tag`.
320    ///
321    /// # Panics
322    ///
323    /// Panics if `tag` is not a valid ASCII XML `NCName`.
324    #[must_use]
325    pub fn sanitized_block(mut self, tag: &str) -> Self {
326        if !is_sanitized_block_str(&self.content, Some(tag), None) {
327            self.content =
328                sanitize_block_str(&self.content, Some(tag), None).expect("tag is a valid XML tag");
329        }
330        self
331    }
332
333    /// Return a sanitized copy of this [`ToolOutput`], wrapped in `<tag>` with a custom
334    /// `notice` and control tokens neutralized.
335    ///
336    /// Idempotent when called on an already-sanitized block with matching `tag`.
337    ///
338    /// # Panics
339    ///
340    /// Panics if `tag` is not a valid ASCII XML `NCName`.
341    #[must_use]
342    pub fn sanitized_with(mut self, tag: &str, notice: &str) -> Self {
343        if !is_sanitized_block_str(&self.content, Some(tag), Some(notice)) {
344            self.content = sanitize_block_str(&self.content, Some(tag), Some(notice))
345                .expect("tag is a valid XML tag");
346        }
347        self
348    }
349
350    /// Returns `true` if this output is wrapped in a verified `<tag>` boundary envelope.
351    #[must_use]
352    pub fn is_sanitized_with(&self, tag: &str) -> bool {
353        if is_sanitized_block_str(&self.content, Some(tag), None) {
354            return true;
355        }
356        let Some(rest) = self
357            .content
358            .strip_prefix('<')
359            .and_then(|r| r.strip_prefix(tag))
360            .and_then(|r| r.strip_prefix('>'))
361        else {
362            return false;
363        };
364        let Some(inner) = rest
365            .strip_suffix('>')
366            .and_then(|r| r.strip_suffix(tag))
367            .and_then(|r| r.strip_suffix("</"))
368        else {
369            return false;
370        };
371        !md_tmpl_core::has_untrusted_breakout(inner, tag)
372    }
373
374    /// Extract the inner payload if this output is a verified `<tag>` boundary block.
375    #[must_use]
376    pub fn unsanitized_with<'a>(&'a self, tag: &str) -> Option<&'a str> {
377        if let Some(payload) = unsanitize_block_str(&self.content, Some(tag), None) {
378            return Some(payload);
379        }
380        let rest = self
381            .content
382            .strip_prefix('<')
383            .and_then(|r| r.strip_prefix(tag))
384            .and_then(|r| r.strip_prefix('>'))?;
385        let body = rest
386            .strip_suffix('>')
387            .and_then(|r| r.strip_suffix(tag))
388            .and_then(|r| r.strip_suffix("</"))?;
389        let inner = body.strip_prefix('\n').unwrap_or(body);
390        let inner = inner.strip_suffix('\n').unwrap_or(inner);
391        let payload = if let Some((_notice_line, after)) = inner.split_once('\n') {
392            after.strip_prefix('\n').unwrap_or(after)
393        } else {
394            inner
395        };
396        if md_tmpl_core::has_untrusted_breakout(payload, tag) {
397            return None;
398        }
399        Some(payload)
400    }
401
402    /// Returns `true` if this output is wrapped in `<tag>` with the exact specified `notice`.
403    #[must_use]
404    pub fn is_sanitized_with_notice(&self, tag: &str, notice: &str) -> bool {
405        is_sanitized_block_str(&self.content, Some(tag), Some(notice))
406    }
407
408    /// Extract the inner payload if this output is wrapped in `<tag>` with the exact specified `notice`.
409    #[must_use]
410    pub fn unsanitized_with_notice<'a>(&'a self, tag: &str, notice: &str) -> Option<&'a str> {
411        unsanitize_block_str(&self.content, Some(tag), Some(notice))
412    }
413
414    /// Sanitize and truncate this [`ToolOutput`] according to `policy`, ensuring
415    /// that control tokens and any split tokens formed across the truncation cut
416    /// boundary are neutralized while respecting `policy.max_bytes`.
417    #[must_use]
418    pub fn sanitized_truncated(mut self, policy: &TruncationPolicy) -> Self {
419        self.content = policy.truncate_sanitized(&self.content).into_owned();
420        self
421    }
422
423    /// Quarantine and truncate this [`ToolOutput`] according to `policy`, preserving
424    /// the outer `<tool_output_quarantine>` envelope and neutralizing any split
425    /// tokens formed across the truncation cut boundary while fitting the total
426    /// envelope inside `policy.max_bytes`.
427    #[must_use]
428    pub fn quarantined_truncated(mut self, policy: &TruncationPolicy) -> Self {
429        self.content = policy.truncate_quarantined(&self.content);
430        self
431    }
432}