Sanitize Tool Results
The Other Direction of Trust
Lessons 176 and 177 checked what the model asks tools to do. The results that tools send back also need care, and for a different reason. A tool result becomes part of the model's context, so whatever it contains is read by the model, may be repeated to the user, and may be stored in logs and transcripts. A result can carry a terminal escape sequence that rewrites a log viewer's display, an email address that should not be shown to every user, a secret from a configuration file, or a page of text far larger than the answer needs. It can also carry instructions written by an attacker, which is the indirect injection path from Lesson 175.
Sanitizing tool results is the step that makes them safe to hand to the model. It removes what should not be there, bounds what is too large, redacts what must not be exposed, and records what was changed, so that the application can explain its handling. The principle is that the tool result is data from a source the application does not control, and it is cleaned like any other untrusted input.
What Sanitizing Covers
A sanitizing step handles a short list of concerns, each for a specific reason:
| Concern | Why it matters | Action |
|---|---|---|
| Terminal escape codes | They can change how logs and terminals display text | Remove them |
| Control characters | They corrupt logs and parsing | Remove them, as Lesson 174 did for user input |
| Personal or secret values | They should not reach the model or the user unnecessarily | Redact them |
| Excess length | Large results waste tokens and bury the useful part | Truncate, and say that it was truncated |
| Unexpected structure | A result the schema does not expect may carry extra fields | Keep only the fields the application uses |
The last row matters for structured results. A tool that returns a record should return only the fields the model needs, and the sanitizer should drop everything else, because fields that are not needed are fields that can leak.
Recording What Changed
Sanitizing changes the data, and the application should know what it changed. A sanitizer that returns notes alongside the cleaned text makes the handling visible: a log can show that an email address was redacted from a result, and a reviewer can see why a result was shorter than the original. Silent changes are hard to trust, and recorded ones can be audited.
A Sanitizer
The function below applies the steps in order, and returns the cleaned text together with a list of notes. It reuses the control character pattern from the input validation in Lesson 174, and adds the handling for escape codes, email addresses, and length:
"""sanitize_result.py: clean a tool result before it reaches the model, and record what changed."""
import re
ESCAPE_CODES = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
def sanitize_result(text: str, max_chars: int = 200) -> tuple[str, list[str]]:
notes: list[str] = []
cleaned = ESCAPE_CODES.sub("", text)
if cleaned != text:
notes.append("removed terminal escape codes")
cleaned_control = CONTROL.sub("", cleaned)
if cleaned_control != cleaned:
notes.append("removed control characters")
cleaned = cleaned_control
emails = EMAIL.findall(cleaned)
if emails:
cleaned = EMAIL.sub("[email removed]", cleaned)
notes.append(f"redacted {len(emails)} email address(es)")
if len(cleaned) > max_chars:
cleaned = cleaned[:max_chars] + " [truncated]"
notes.append(f"truncated to {max_chars} characters")
return cleaned, notes
if __name__ == "__main__":
cases = [
"\x1b[31mRED\x1b[0m status ok",
"Contact support at help@example.com for refunds.",
"x" * 250,
]
for number, raw in enumerate(cases, start=1):
cleaned, notes = sanitize_result(raw, max_chars=20) if number == 3 else sanitize_result(raw)
print(f"case {number}: {cleaned[:60]!r} notes={notes}")
Output:
case 1: 'RED status ok' notes=['removed terminal escape codes']
case 2: 'Contact support at [email removed] for refunds.' notes=['redacted 1 email address(es)']
case 3: 'xxxxxxxxxxxxxxxxxxxx [truncated]' notes=['truncated to 20 characters']
How It Works
- Escape codes are removed first. The sequence in case one colors the text red in a terminal, and the pattern matches the whole sequence, leaving the word itself, so the result is plain text.
- Control characters follow. Any remaining non-printing characters are removed, and the note is added only if something was actually removed.
- Email addresses are redacted and counted. Case two's address is replaced with a placeholder, and the note states how many were removed, which lets a reviewer see that redaction happened.
- Length is applied last, to the cleaned text, so the limit measures what the model would see. Truncation is marked with a visible suffix, so the model knows the result was cut and does not assume it is complete (Lesson 87 made the same point about notes).
- Each change adds a note, so the list describes the full set of transformations applied to this result.
Structured Results
A tool that returns a record should be sanitized field by field, against an allowlist. The application decides which fields the model needs, and anything else is dropped before the result is serialized. A new field added to the backend is then excluded by default, which is the safe direction for a change to fail.
Each allowed string field goes through the same text cleaning as above, and fields with a known format, such as a date or an identifier, are checked against that format. A record that fails the check is rejected, and the rejection is reported to the model as a tool error (Lesson 90) rather than passed through in a damaged form.
Screening the Content Too
Sanitizing removes what should not be there. It does not decide whether the remaining text contains instructions. A result that passes every check here may still contain an injection, and the screen from Lesson 175 applies to tool results in the same way it applies to fetched content. The two steps are complementary: sanitizing shapes the data, and screening judges its intent.
Summary
Tool results enter the model's context and may be shown, logged, or repeated, so they are sanitized like any untrusted input. Escape codes, control characters, personal values, and excess length are handled in a fixed order, and each change is recorded in a note. Structured results pass through an allowlist of fields with format checks, and rejected records become tool errors. Sanitizing shapes the data, and the injection screen judges its content, so both steps are applied. The next lesson addresses the costs and damage that a runaway application can cause, through usage limits.