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}