#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Sanitized {
pub text: String,
pub modified: bool,
pub suspicious: bool,
}
impl Sanitized {
pub fn fenced(&self, label: &str) -> String {
format!("<untrusted:{label}>{}</untrusted:{label}>", self.text)
}
}
pub const NAME_BUDGET: usize = 48;
pub const URI_BUDGET: usize = 96;
pub fn untrusted_text(input: &str, max_chars: usize) -> Sanitized {
let mut cleaned = String::with_capacity(input.len().min(max_chars * 4));
let mut removed_invisible = false;
let mut structural_whitespace = false;
let mut last_was_space = false;
for ch in input.chars() {
if ch.is_whitespace() {
if ch != ' ' {
structural_whitespace = true;
}
if !last_was_space && !cleaned.is_empty() {
cleaned.push(' ');
last_was_space = true;
}
continue;
}
if is_invisible(ch) {
removed_invisible = true;
continue;
}
let ch = match ch {
'`' => '\'',
'<' => '(',
'>' => ')',
other => other,
};
cleaned.push(ch);
last_was_space = false;
}
let cleaned = cleaned.trim_end().to_string();
let truncated = if cleaned.chars().count() > max_chars {
let mut s: String = cleaned.chars().take(max_chars).collect();
s.push('…');
s
} else {
cleaned.clone()
};
let suspicious =
removed_invisible || structural_whitespace || looks_like_an_instruction(&cleaned);
Sanitized {
modified: truncated != input,
suspicious,
text: truncated,
}
}
pub fn untrusted_uri(input: &str) -> Sanitized {
let stripped: String = input.chars().filter(|c| !is_invisible(*c)).collect();
let stripped = stripped.trim();
let origin = match stripped.split_once("://") {
Some((scheme, rest)) => {
let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let host = rest[..host_end].rsplit('@').next().unwrap_or("");
let scheme_ok = scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.');
if !scheme_ok || host.is_empty() {
None
} else if host_end == rest.len() {
Some(format!("{scheme}://{host}"))
} else {
Some(format!("{scheme}://{host}/…"))
}
}
None => None,
};
match origin {
Some(o) => {
let mut s = untrusted_text(&o, URI_BUDGET);
s.modified = o != input;
s
}
None => untrusted_text(stripped, URI_BUDGET),
}
}
fn is_invisible(ch: char) -> bool {
if ch.is_control() {
return true;
}
matches!(ch,
'\u{00AD}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}' | '\u{2066}'..='\u{2069}' | '\u{FEFF}' | '\u{E0000}'..='\u{E007F}' )
}
const INSTRUCTION_MARKERS: [&str; 18] = [
"ignore previous",
"ignore all previous",
"disregard the",
"system:",
"assistant:",
"you are now",
"new instructions",
"override",
"do not warn",
"this is verified",
"safe to approve",
"approve the",
"call the tool",
"tool_call",
"seed phrase",
"private key",
"send all",
"transfer all",
];
fn looks_like_an_instruction(s: &str) -> bool {
let lower = s.to_lowercase();
INSTRUCTION_MARKERS.iter().any(|m| lower.contains(m))
}