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
273fn sanitize_json_value_in_place(val: &mut serde_json::Value) {
274 match val {
275 serde_json::Value::String(s) => {
276 if !is_quarantined_output(s)
277 && let Cow::Owned(clean) =
278 md_tmpl_core::sanitize_untrusted_str(s, TOOL_OUTPUT_QUARANTINE_TAG)
279 {
280 *s = clean;
281 }
282 }
283 serde_json::Value::Array(arr) => {
284 for item in arr {
285 sanitize_json_value_in_place(item);
286 }
287 }
288 serde_json::Value::Object(map) => {
289 for v in map.values_mut() {
290 sanitize_json_value_in_place(v);
291 }
292 }
293 serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
294 }
295}
296
297impl ToolOutput {
298 fn sanitize_metadata_in_place(&mut self) {
299 for val in self.metadata.values_mut() {
300 sanitize_json_value_in_place(val);
301 }
302 }
303
304 /// Return a sanitized copy of this [`ToolOutput`], neutralizing control
305 /// delimiters ([`DELIMITER_RULES`]) and `<tool_output_quarantine>` breakout tags
306 /// in a single pass without wrapping in outer XML tags.
307 ///
308 /// Zero-allocation when the content contains no delimiters, and idempotent when
309 /// called on an already-sanitized or already-quarantined output.
310 #[must_use]
311 pub fn sanitized(mut self) -> Self {
312 if !is_quarantined_output(&self.content)
313 && let Cow::Owned(clean) =
314 md_tmpl_core::sanitize_untrusted_str(&self.content, TOOL_OUTPUT_QUARANTINE_TAG)
315 {
316 self.content = clean;
317 }
318 self.sanitize_metadata_in_place();
319 self
320 }
321
322 /// Returns `true` if this output's content contains no un-neutralized
323 /// [`DELIMITER_RULES`] or `<tool_output_quarantine>` breakout tags (or is
324 /// wrapped in a verified `<tool_output_quarantine>` envelope).
325 #[must_use]
326 pub fn is_sanitized(&self) -> bool {
327 is_sanitized_output(&self.content)
328 }
329
330 /// Return a quarantined copy of this [`ToolOutput`], neutralizing control
331 /// delimiters and wrapping the content in `<tool_output_quarantine>` tags.
332 ///
333 /// Idempotent when called on an already-quarantined output.
334 #[must_use]
335 pub fn quarantined(mut self) -> Self {
336 self.content = quarantine_tool_output(&self.content);
337 self.sanitize_metadata_in_place();
338 self
339 }
340
341 /// Returns `true` if this output's content is wrapped in a verified
342 /// `<tool_output_quarantine>` envelope with no un-neutralized delimiters.
343 #[must_use]
344 pub fn is_quarantined(&self) -> bool {
345 is_quarantined_output(&self.content)
346 }
347
348 /// Return a sanitized copy of this [`ToolOutput`], wrapped in `<tag>` with the default
349 /// boundary notice and control tokens neutralized.
350 ///
351 /// Idempotent when called on an already-sanitized block with matching `tag`.
352 ///
353 /// # Panics
354 ///
355 /// Panics if `tag` is not a valid ASCII XML `NCName`.
356 #[must_use]
357 pub fn sanitized_block(mut self, tag: &str) -> Self {
358 if !is_sanitized_block_str(&self.content, Some(tag), None) {
359 self.content =
360 sanitize_block_str(&self.content, Some(tag), None).expect("tag is a valid XML tag");
361 }
362 self.sanitize_metadata_in_place();
363 self
364 }
365
366 /// Return a sanitized copy of this [`ToolOutput`], wrapped in `<tag>` with a custom
367 /// `notice` and control tokens neutralized.
368 ///
369 /// Idempotent when called on an already-sanitized block with matching `tag`.
370 ///
371 /// # Panics
372 ///
373 /// Panics if `tag` is not a valid ASCII XML `NCName`.
374 #[must_use]
375 pub fn sanitized_with(mut self, tag: &str, notice: &str) -> Self {
376 if !is_sanitized_block_str(&self.content, Some(tag), Some(notice)) {
377 self.content = sanitize_block_str(&self.content, Some(tag), Some(notice))
378 .expect("tag is a valid XML tag");
379 }
380 self.sanitize_metadata_in_place();
381 self
382 }
383
384 /// Returns `true` if this output is wrapped in a verified `<tag>` boundary envelope.
385 #[must_use]
386 pub fn is_sanitized_with(&self, tag: &str) -> bool {
387 if is_sanitized_block_str(&self.content, Some(tag), None) {
388 return true;
389 }
390 let Some(rest) = self
391 .content
392 .strip_prefix('<')
393 .and_then(|r| r.strip_prefix(tag))
394 .and_then(|r| r.strip_prefix('>'))
395 else {
396 return false;
397 };
398 let Some(inner) = rest
399 .strip_suffix('>')
400 .and_then(|r| r.strip_suffix(tag))
401 .and_then(|r| r.strip_suffix("</"))
402 else {
403 return false;
404 };
405 !md_tmpl_core::has_untrusted_breakout(inner, tag)
406 }
407
408 /// Extract the inner payload if this output is a verified `<tag>` boundary block.
409 #[must_use]
410 pub fn unsanitized_with<'a>(&'a self, tag: &str) -> Option<&'a str> {
411 if let Some(payload) = unsanitize_block_str(&self.content, Some(tag), None) {
412 return Some(payload);
413 }
414 let rest = self
415 .content
416 .strip_prefix('<')
417 .and_then(|r| r.strip_prefix(tag))
418 .and_then(|r| r.strip_prefix('>'))?;
419 let body = rest
420 .strip_suffix('>')
421 .and_then(|r| r.strip_suffix(tag))
422 .and_then(|r| r.strip_suffix("</"))?;
423 let inner = body.strip_prefix('\n').unwrap_or(body);
424 let inner = inner.strip_suffix('\n').unwrap_or(inner);
425 let payload = if let Some((_notice_line, after)) = inner.split_once('\n') {
426 after.strip_prefix('\n').unwrap_or(after)
427 } else {
428 inner
429 };
430 if md_tmpl_core::has_untrusted_breakout(payload, tag) {
431 return None;
432 }
433 Some(payload)
434 }
435
436 /// Returns `true` if this output is wrapped in `<tag>` with the exact specified `notice`.
437 #[must_use]
438 pub fn is_sanitized_with_notice(&self, tag: &str, notice: &str) -> bool {
439 is_sanitized_block_str(&self.content, Some(tag), Some(notice))
440 }
441
442 /// Extract the inner payload if this output is wrapped in `<tag>` with the exact specified `notice`.
443 #[must_use]
444 pub fn unsanitized_with_notice<'a>(&'a self, tag: &str, notice: &str) -> Option<&'a str> {
445 unsanitize_block_str(&self.content, Some(tag), Some(notice))
446 }
447
448 /// Sanitize and truncate this [`ToolOutput`] according to `policy`, ensuring
449 /// that control tokens and any split tokens formed across the truncation cut
450 /// boundary are neutralized while respecting `policy.max_bytes`.
451 #[must_use]
452 pub fn sanitized_truncated(mut self, policy: &TruncationPolicy) -> Self {
453 self.content = policy.truncate_sanitized(&self.content).into_owned();
454 self.sanitize_metadata_in_place();
455 self
456 }
457
458 /// Quarantine and truncate this [`ToolOutput`] according to `policy`, preserving
459 /// the outer `<tool_output_quarantine>` envelope and neutralizing any split
460 /// tokens formed across the truncation cut boundary while fitting the total
461 /// envelope inside `policy.max_bytes`.
462 #[must_use]
463 pub fn quarantined_truncated(mut self, policy: &TruncationPolicy) -> Self {
464 self.content = policy.truncate_quarantined(&self.content);
465 self.sanitize_metadata_in_place();
466 self
467 }
468}