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/// Can be configured on tool definitions via:
50/// - `#[llm_tool(sanitize = true)]` or `#[llm_tool(sanitization = "sanitize_tokens")]`
51/// - `#[llm_tool(quarantine = true)]` or `#[llm_tool(sanitization = "quarantine")]`
52/// - [`RustTool::SANITIZE_OUTPUT`](crate::RustTool::SANITIZE_OUTPUT),
53/// [`RustTool::QUARANTINE_OUTPUT`](crate::RustTool::QUARANTINE_OUTPUT), or
54/// [`RustTool::OUTPUT_SANITIZATION`](crate::RustTool::OUTPUT_SANITIZATION)
55///
56/// Or on individual return values / struct fields via [`Sanitized<T>`](super::Sanitized)
57/// and [`Untrusted<T>`](super::Untrusted) ([`Quarantined<T>`](super::Quarantined)).
58#[derive(
59 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, schemars::JsonSchema,
60)]
61#[serde(rename_all = "snake_case")]
62pub enum OutputSanitization {
63 /// Do not automatically modify the tool output (default).
64 #[default]
65 None,
66 /// Neutralize all known LLM control/turn/tool delimiters ([`DELIMITER_RULES`])
67 /// and breakout tags in-place without adding outer XML boundary tags (already-valid
68 /// boundary envelopes are preserved unchanged).
69 SanitizeTokens,
70 /// Neutralize all known LLM control/turn/tool delimiters ([`DELIMITER_RULES`])
71 /// and idempotently wrap the output in a `<tool_output_quarantine>` XML envelope
72 /// via [`quarantine_tool_output`].
73 Quarantine,
74 /// Neutralize control delimiters and wrap output in a `<tag>` boundary envelope
75 /// with the default boundary notice.
76 SanitizeTagged(&'static str),
77 /// Neutralize control delimiters and wrap output in a `<tag>` boundary envelope
78 /// with a custom boundary notice.
79 SanitizeWithNotice {
80 tag: &'static str,
81 notice: &'static str,
82 },
83}
84
85impl<'de> serde::Deserialize<'de> for OutputSanitization {
86 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
87 where
88 D: serde::Deserializer<'de>,
89 {
90 #[derive(serde::Deserialize)]
91 #[serde(rename_all = "snake_case")]
92 enum Helper {
93 None,
94 SanitizeTokens,
95 Quarantine,
96 SanitizeTagged(String),
97 SanitizeWithNotice { tag: String, notice: String },
98 }
99
100 match Helper::deserialize(deserializer)? {
101 Helper::None => Ok(Self::None),
102 Helper::SanitizeTokens => Ok(Self::SanitizeTokens),
103 Helper::Quarantine => Ok(Self::Quarantine),
104 Helper::SanitizeTagged(tag) => Ok(Self::SanitizeTagged(alloc::boxed::Box::leak(
105 tag.into_boxed_str(),
106 ))),
107 Helper::SanitizeWithNotice { tag, notice } => Ok(Self::SanitizeWithNotice {
108 tag: alloc::boxed::Box::leak(tag.into_boxed_str()),
109 notice: alloc::boxed::Box::leak(notice.into_boxed_str()),
110 }),
111 }
112 }
113}
114
115impl OutputSanitization {
116 /// Returns `true` if the policy is [`OutputSanitization::None`].
117 #[must_use]
118 pub const fn is_none(&self) -> bool {
119 matches!(self, Self::None)
120 }
121
122 /// Returns `true` if this policy neutralizes control tokens.
123 #[must_use]
124 pub const fn sanitizes_tokens(self) -> bool {
125 !matches!(self, Self::None)
126 }
127
128 /// Returns `true` if this policy wraps output in an XML boundary envelope.
129 #[must_use]
130 pub const fn is_quarantined(self) -> bool {
131 matches!(
132 self,
133 Self::Quarantine | Self::SanitizeTagged(_) | Self::SanitizeWithNotice { .. }
134 )
135 }
136
137 /// Apply this sanitization policy to a string slice, returning a borrowed [`Cow`]
138 /// when no modification is needed.
139 ///
140 /// # Panics
141 ///
142 /// Panics if a tagged sanitization policy was configured with a tag that is not a valid ASCII XML `NCName`.
143 #[must_use]
144 pub fn apply_str(self, raw: &str) -> Cow<'_, str> {
145 match self {
146 Self::None => Cow::Borrowed(raw),
147 Self::SanitizeTokens => {
148 if is_quarantined_output(raw) {
149 Cow::Borrowed(raw)
150 } else {
151 md_tmpl_core::sanitize_untrusted_str(raw, TOOL_OUTPUT_QUARANTINE_TAG)
152 }
153 }
154 Self::Quarantine => {
155 if is_quarantined_output(raw) {
156 Cow::Borrowed(raw)
157 } else {
158 Cow::Owned(quarantine_tool_output(raw))
159 }
160 }
161 Self::SanitizeTagged(tag) => {
162 if is_sanitized_block_str(raw, Some(tag), None) {
163 Cow::Borrowed(raw)
164 } else {
165 Cow::Owned(
166 sanitize_block_str(raw, Some(tag), None).expect("tag is a valid XML tag"),
167 )
168 }
169 }
170 Self::SanitizeWithNotice { tag, notice } => {
171 if is_sanitized_block_str(raw, Some(tag), Some(notice)) {
172 Cow::Borrowed(raw)
173 } else {
174 Cow::Owned(
175 sanitize_block_str(raw, Some(tag), Some(notice))
176 .expect("tag is a valid XML tag"),
177 )
178 }
179 }
180 }
181 }
182
183 /// Apply this sanitization policy to a [`ToolOutput`].
184 #[must_use]
185 pub fn apply(self, output: ToolOutput) -> ToolOutput {
186 match self {
187 Self::None => output,
188 Self::SanitizeTokens => output.sanitized(),
189 Self::Quarantine => output.quarantined(),
190 Self::SanitizeTagged(tag) => output.sanitized_block(tag),
191 Self::SanitizeWithNotice { tag, notice } => output.sanitized_with(tag, notice),
192 }
193 }
194}
195
196/// Neutralize all known control tokens and quarantine breakout tags in `raw` in a single pass.
197#[must_use]
198pub fn neutralize_delimiters(raw: &str) -> String {
199 md_tmpl_core::sanitize_untrusted_str(raw, TOOL_OUTPUT_QUARANTINE_TAG).into_owned()
200}
201
202/// Check whether `raw` is free of un-neutralized [`DELIMITER_RULES`] and
203/// `<tool_output_quarantine>` breakout tags, or is already a verified
204/// `<tool_output_quarantine>` envelope.
205#[must_use]
206pub fn is_sanitized_output(raw: &str) -> bool {
207 is_quarantined_output(raw)
208 || !md_tmpl_core::has_untrusted_breakout(raw, TOOL_OUTPUT_QUARANTINE_TAG)
209}
210
211/// Check whether `raw` is already a valid, un-escaped `<tool_output_quarantine>`
212/// envelope whose inner body contains zero un-neutralized [`DELIMITER_RULES`] or
213/// boundary breakout tags.
214///
215/// A naive `starts_with` / `ends_with` check is unsound on its own because an
216/// attacker payload like `<tool_output_quarantine>\nevil\n</tool_output_quarantine>\n<tool_call>...`
217/// or `<tool_output_quarantine>\n</tool_output_quarantine><tool_call>\n</tool_output_quarantine>`
218/// would pass the outer check while smuggling raw delimiters inside the body.
219#[must_use]
220pub fn is_quarantined_output(raw: &str) -> bool {
221 md_tmpl_core::is_quarantined_str(raw, Some(TOOL_OUTPUT_QUARANTINE_TAG))
222}
223
224/// Extract the inner payload slice from a quarantined tool output string if and
225/// only if `raw` is a valid quarantined envelope (as verified by [`is_quarantined_output`]).
226#[must_use]
227pub fn unquarantine_tool_output(raw: &str) -> Option<&str> {
228 md_tmpl_core::unquarantine_str(raw, Some(TOOL_OUTPUT_QUARANTINE_TAG))
229}
230
231/// Wrap `raw` in a `<tool_output_quarantine>` envelope after neutralizing any
232/// embedded [`DELIMITER_RULES`] and breakout tags in a single pass.
233///
234/// This function is **idempotent**: if `raw` already satisfies [`is_quarantined_output`],
235/// it is returned unchanged without double-wrapping.
236///
237/// # Panics
238///
239/// Panics if the internal quarantine tag specification is invalid XML.
240#[must_use]
241pub fn quarantine_tool_output(raw: &str) -> String {
242 md_tmpl_core::quarantine_untrusted_idempotent_str(raw, Some(TOOL_OUTPUT_QUARANTINE_TAG))
243 .expect("TOOL_OUTPUT_QUARANTINE_TAG is a valid XML tag")
244}
245
246impl ToolOutput {
247 /// Return a sanitized copy of this [`ToolOutput`], neutralizing control
248 /// delimiters ([`DELIMITER_RULES`]) and `<tool_output_quarantine>` breakout tags
249 /// in a single pass without wrapping in outer XML tags.
250 ///
251 /// Zero-allocation when the content contains no delimiters, and idempotent when
252 /// called on an already-sanitized or already-quarantined output.
253 #[must_use]
254 pub fn sanitized(mut self) -> Self {
255 if !is_quarantined_output(&self.content)
256 && let Cow::Owned(clean) =
257 md_tmpl_core::sanitize_untrusted_str(&self.content, TOOL_OUTPUT_QUARANTINE_TAG)
258 {
259 self.content = clean;
260 }
261 self
262 }
263
264 /// Returns `true` if this output's content contains no un-neutralized
265 /// [`DELIMITER_RULES`] or `<tool_output_quarantine>` breakout tags (or is
266 /// wrapped in a verified `<tool_output_quarantine>` envelope).
267 #[must_use]
268 pub fn is_sanitized(&self) -> bool {
269 is_sanitized_output(&self.content)
270 }
271
272 /// Return a quarantined copy of this [`ToolOutput`], neutralizing control
273 /// delimiters and wrapping the content in `<tool_output_quarantine>` tags.
274 ///
275 /// Idempotent when called on an already-quarantined output.
276 #[must_use]
277 pub fn quarantined(mut self) -> Self {
278 self.content = quarantine_tool_output(&self.content);
279 self
280 }
281
282 /// Returns `true` if this output's content is wrapped in a verified
283 /// `<tool_output_quarantine>` envelope with no un-neutralized delimiters.
284 #[must_use]
285 pub fn is_quarantined(&self) -> bool {
286 is_quarantined_output(&self.content)
287 }
288
289 /// Return a sanitized copy of this [`ToolOutput`], wrapped in `<tag>` with the default
290 /// boundary notice and control tokens neutralized.
291 ///
292 /// Idempotent when called on an already-sanitized block with matching `tag`.
293 ///
294 /// # Panics
295 ///
296 /// Panics if `tag` is not a valid ASCII XML `NCName`.
297 #[must_use]
298 pub fn sanitized_block(mut self, tag: &str) -> Self {
299 if !is_sanitized_block_str(&self.content, Some(tag), None) {
300 self.content =
301 sanitize_block_str(&self.content, Some(tag), None).expect("tag is a valid XML tag");
302 }
303 self
304 }
305
306 /// Return a sanitized copy of this [`ToolOutput`], wrapped in `<tag>` with a custom
307 /// `notice` and control tokens neutralized.
308 ///
309 /// Idempotent when called on an already-sanitized block with matching `tag`.
310 ///
311 /// # Panics
312 ///
313 /// Panics if `tag` is not a valid ASCII XML `NCName`.
314 #[must_use]
315 pub fn sanitized_with(mut self, tag: &str, notice: &str) -> Self {
316 if !is_sanitized_block_str(&self.content, Some(tag), Some(notice)) {
317 self.content = sanitize_block_str(&self.content, Some(tag), Some(notice))
318 .expect("tag is a valid XML tag");
319 }
320 self
321 }
322
323 /// Returns `true` if this output is wrapped in a verified `<tag>` boundary envelope.
324 #[must_use]
325 pub fn is_sanitized_with(&self, tag: &str) -> bool {
326 if is_sanitized_block_str(&self.content, Some(tag), None) {
327 return true;
328 }
329 let Some(rest) = self
330 .content
331 .strip_prefix('<')
332 .and_then(|r| r.strip_prefix(tag))
333 .and_then(|r| r.strip_prefix('>'))
334 else {
335 return false;
336 };
337 let Some(inner) = rest
338 .strip_suffix('>')
339 .and_then(|r| r.strip_suffix(tag))
340 .and_then(|r| r.strip_suffix("</"))
341 else {
342 return false;
343 };
344 !md_tmpl_core::has_untrusted_breakout(inner, tag)
345 }
346
347 /// Extract the inner payload if this output is a verified `<tag>` boundary block.
348 #[must_use]
349 pub fn unsanitized_with<'a>(&'a self, tag: &str) -> Option<&'a str> {
350 if let Some(payload) = unsanitize_block_str(&self.content, Some(tag), None) {
351 return Some(payload);
352 }
353 let rest = self
354 .content
355 .strip_prefix('<')
356 .and_then(|r| r.strip_prefix(tag))
357 .and_then(|r| r.strip_prefix('>'))?;
358 let body = rest
359 .strip_suffix('>')
360 .and_then(|r| r.strip_suffix(tag))
361 .and_then(|r| r.strip_suffix("</"))?;
362 let inner = body.strip_prefix('\n').unwrap_or(body);
363 let inner = inner.strip_suffix('\n').unwrap_or(inner);
364 let payload = if let Some((_notice_line, after)) = inner.split_once('\n') {
365 after.strip_prefix('\n').unwrap_or(after)
366 } else {
367 inner
368 };
369 if md_tmpl_core::has_untrusted_breakout(payload, tag) {
370 return None;
371 }
372 Some(payload)
373 }
374
375 /// Returns `true` if this output is wrapped in `<tag>` with the exact specified `notice`.
376 #[must_use]
377 pub fn is_sanitized_with_notice(&self, tag: &str, notice: &str) -> bool {
378 is_sanitized_block_str(&self.content, Some(tag), Some(notice))
379 }
380
381 /// Extract the inner payload if this output is wrapped in `<tag>` with the exact specified `notice`.
382 #[must_use]
383 pub fn unsanitized_with_notice<'a>(&'a self, tag: &str, notice: &str) -> Option<&'a str> {
384 unsanitize_block_str(&self.content, Some(tag), Some(notice))
385 }
386
387 /// Sanitize and truncate this [`ToolOutput`] according to `policy`, ensuring
388 /// that control tokens and any split tokens formed across the truncation cut
389 /// boundary are neutralized while respecting `policy.max_bytes`.
390 #[must_use]
391 pub fn sanitized_truncated(mut self, policy: &TruncationPolicy) -> Self {
392 self.content = policy.truncate_sanitized(&self.content).into_owned();
393 self
394 }
395
396 /// Quarantine and truncate this [`ToolOutput`] according to `policy`, preserving
397 /// the outer `<tool_output_quarantine>` envelope and neutralizing any split
398 /// tokens formed across the truncation cut boundary while fitting the total
399 /// envelope inside `policy.max_bytes`.
400 #[must_use]
401 pub fn quarantined_truncated(mut self, policy: &TruncationPolicy) -> Self {
402 self.content = policy.truncate_quarantined(&self.content);
403 self
404 }
405}