ANSI escape sequence sanitization for agent-safe terminals
ANSI escape sequence sanitization keeps terminal controls, OSC payloads, and cursor tricks out of AI agent context while retaining usable command results.

Terminal output is input. Once an agent can read a command result, repository file, build log, SSH banner, or API response, every byte in that result can try to alter what the agent sees or how a nearby terminal behaves. Treating stdout as trustworthy because your own command produced it is a mistake. The command often relays data that somebody else controlled.
ANSI escape sequence sanitization belongs at the boundary where tool output becomes agent context. It should happen before a model receives text, before a human opens a live transcript, and before a session log becomes replayable. Removing color codes after the model has already read them only cleans up evidence.
This is not an argument for turning every command result into a sterile blob. Agents need useful diagnostics. The job is to preserve the semantic content of output while refusing to carry terminal instructions, cursor movement, clipboard requests, hyperlinks, and other control traffic across a trust boundary.
A terminal transcript is untrusted input
A shell command does not own its output. git log prints commit messages. A compiler prints paths and source excerpts. A test runner may repeat a fixture. An SSH client displays a server banner before it gives you a prompt. A package manager reads names, versions, metadata, and error messages from remote registries. In each case, an outside party can influence text that later reaches an agent.
The common failure starts with a useful convenience: capture stdout and stderr, concatenate them, and append the result to the agent's conversation. That design gives the output two jobs. It reports a program result and acts as instructions in a language model's context. Terminal controls make the first job less reliable, and prompt-shaped content makes the second dangerous.
Consider a repository that contains a file name with a carriage return and an escape sequence. A command that lists the file can produce a display that overwrites its own prefix. A reviewer sees a harmless path. The raw byte stream contains something else. If a tool then turns that stream into an agent message without showing byte boundaries or applying a control policy, the agent receives an ambiguous artifact.
Do not limit the threat model to a malicious repository contributor. Build artifacts, stack traces from dependencies, network devices, database errors, and remote command output all cross the same boundary. A compromised server does not need shell access on the agent host to return a banner crafted to pollute a transcript.
There are two separate risks:
- A terminal emulator may execute control instructions when a person views raw output.
- An agent may interpret visible text, hidden text, or reordered text as directions instead of data.
A clean agent transcript reduces the second risk. A viewer that never interprets terminal controls reduces the first. You need both when humans inspect captured output.
Terminal controls do more than add color
Terminal control sequences can move a cursor, erase prior text, set a window title, create a clickable link, place content on a clipboard, query a terminal, or transmit data to a terminal feature. SGR color is only the familiar subset. A filter that removes ESC[ followed by digits and m handles colors while leaving much more capable sequences intact.
ECMA-48 defines control functions and the general form of CSI sequences. CSI usually starts with ESC followed by [, then parameter bytes, intermediate bytes, and a final byte. It also permits single-byte C1 forms in the range 0x80 through 0x9f. Terminal emulators add private behavior on top of that standard. The xterm control-sequences documentation describes OSC, DCS, APC, PM, and SOS strings, including terminators that differ from a CSI sequence.
That grammar matters because controls do not always arrive as a neat line of text. A program can write ESC in one chunk and [ in the next. A pseudo-terminal can split output at any byte. A tool can emit an OSC string that uses BEL as a terminator, or the two-byte ST form ESC followed by backslash. A correct boundary component must keep state across reads.
Some examples explain why a color stripper is insufficient:
ESC[2Jasks the terminal to erase the display.ESC[Hmoves the cursor to the home position.ESC]8;;URI ESC\begins an OSC 8 hyperlink in terminals that support it.ESC]52;... BELis the clipboard operation that many terminal emulators recognize.- A carriage return returns the cursor to column zero and can overwrite an earlier status line.
You do not need every terminal to support a sequence for the sequence to matter. An output collector cannot predict which terminal an engineer will use six months later, which viewer will replay a log, or which parser will turn a control byte into a visible token. Remove the ambiguity at collection time.
Control characters outside escape sequences deserve attention too. Backspace, carriage return, form feed, bell, and many C1 bytes can change presentation or confuse line-oriented parsing. Preserve only the controls your output contract needs, usually line feed and perhaps tab. Preserve carriage return only when a parser gives it an explicit, tested meaning.
Strip controls before text reaches the model
The safest default is simple: collect bytes, cap their size, parse terminal controls as bytes, discard control instructions, decode the remaining printable content with a defined error policy, and send that cleaned text to the agent. Do not decode first and hope a Unicode cleanup routine finds the dangerous material. ESC is an ASCII byte, C1 controls may appear directly, and malformed byte sequences must not cause the collector to skip filtering.
A practical output path has four records, even if you only expose one to the agent. Keep the raw byte stream in protected storage for investigation. Produce normalized plain text for model context. Produce a removal report for operators and logs. Store metadata such as exit status, duration, truncation state, byte count, and a cryptographic digest of the raw content.
The removal report matters. Silent deletion can hide a problem from the person who needs to investigate it. A useful report might say that the collector removed two CSI sequences, one OSC string, three carriage returns, and one invalid byte sequence. It does not need to reproduce the payload. Repeating an OSC payload in the report can reintroduce the same hazard.
Make the boundary apply to stdout and stderr separately before you merge them. Programs interleave streams in ways that do not preserve source order once a wrapper combines them. If an agent needs a single narrative, label the two cleaned streams and include a sequence number assigned by the collector. That preserves more truth than a magical transcript that claims a precise order it never observed.
Size limits belong in the same component. An attacker can use a never-terminated OSC string or plain repetitive output to consume memory and context. Put a hard limit on total captured bytes and a smaller limit on any in-progress control string. When the limit fires, close or drain the source according to your process policy, mark the result truncated, and keep the partial raw artifact out of the agent message.
Do not ask a model to decide whether an escape sequence is harmless. Models parse text, not byte protocols, and their decision can vary with surrounding context. A deterministic parser should make that decision before the model sees the output.
Isolation preserves evidence without preserving behavior
Stripping and isolation solve different problems. Stripping creates usable text by removing presentation instructions. Isolation keeps original bytes available for a person who has a legitimate reason to inspect them. Calling a raw capture "sanitized" because you base64 encoded it confuses transport with authorization.
An agent usually needs the first few hundred lines of a failing build, not a byte-perfect replay of a terminal session. Give it normalized text with explicit markers for truncation and removals. If it needs more detail, let it request a narrow, cleaned excerpt by line range or search term. Do not respond by pasting the raw capture into the same context.
When an investigator needs the original, open it in a byte-oriented viewer that renders control bytes visibly and never sends them to a terminal. Hex dumps work well because they make byte boundaries obvious. A display that substitutes ^[ for ESC can help, but it must also handle C1 characters and string payloads correctly. A viewer that runs cat on the evidence is not a forensic tool.
This shell command creates a sample file without emitting its controls into your current terminal. The file contains red-text SGR, a cursor-up CSI sequence, an OSC 8 hyperlink, and a carriage return:
printf 'build: \033[31mFAIL\033[0m\nnotice\033[1A\033]8;;https://example.invalid\033\\open\033]8;;\033\\\rPASS\n' > terminal-sample.bin
od -An -tx1c terminal-sample.bin
The od output should contain 1b for ESC and 0d for carriage return. It should never cause your terminal to follow the link or move its cursor because od prints a representation of the bytes instead of replaying them.
Isolation also needs access control. A raw artifact may include secrets that a command printed by mistake. Sanitizing terminal controls does not redact tokens, passwords, or personal data. Run secret detection and redaction as a separate stage with its own false-positive policy. Do not merge the two jobs, because a redactor that misses a credential must not also decide whether an OSC 52 request survives.
Regex is not a terminal parser
A regular expression remains popular because it removes colorful build output in one line. It fails as a security boundary because terminal grammar is stateful and streaming. Many patterns also have performance problems on long malformed input, exactly the input an adversary can supply.
Use a byte state machine with explicit behavior for ESC, CSI, and string controls. The following Python function is deliberately narrow. It preserves tab and line feed, maps carriage return to a visible line break policy, drops other C0 and C1 controls, and removes ESC sequences including OSC, DCS, APC, PM, and SOS strings. It accepts chunks only after the caller has combined them, so a production version should retain the state fields between reads.
def clean_terminal_bytes(data: bytes) -> tuple[str, dict[str, int]]:
out = bytearray()
counts = {"esc": 0, "csi": 0, "string": 0, "control": 0}
i = 0
while i < len(data):
b = data[i]
if b == 0x1b: # ESC
counts["esc"] += 1
i += 1
if i >= len(data):
break
nxt = data[i]
if nxt == ord('['): # CSI
counts["csi"] += 1
i += 1
while i < len(data):
c = data[i]
i += 1
if 0x40 <= c <= 0x7e:
break
continue
if nxt in b']P_^X': # OSC, DCS, APC, PM, SOS
counts["string"] += 1
i += 1
while i < len(data):
c = data[i]
if c == 0x07: # BEL
i += 1
break
if c == 0x1b and i + 1 < len(data) and data[i + 1] == ord('\\'):
i += 2
break
i += 1
continue
i += 1 # Two-byte ESC function or unknown ESC form
continue
if b == 0x9b: # Single-byte C1 CSI
counts["csi"] += 1
i += 1
while i < len(data):
c = data[i]
i += 1
if 0x40 <= c <= 0x7e:
break
continue
if 0x80 <= b <= 0x9f or b < 0x20 and b not in (0x09, 0x0a):
counts["control"] += 1
i += 1
continue
out.append(b)
i += 1
return out.decode("utf-8", errors="replace"), counts
This example has limits. It does not model every ECMA-48 control function, and it treats unknown ESC forms as removable. That conservatism is appropriate when output enters an agent context. A terminal emulator needs broad compatibility. An agent boundary needs a small accepted surface.
Do not copy this function and declare the job finished. Add maximum lengths for CSI parameters and string controls. Preserve parser state across read boundaries. Count malformed and unterminated sequences. Most importantly, write tests that assert the raw payload never appears in the cleaned result. A security filter needs negative tests, not only attractive before-and-after screenshots.
OSC sequences deserve special treatment
OSC strings are where many teams discover that terminal output carries more than formatting. Xterm documents OSC commands for such things as window titles and hyperlinks. Modern terminal emulators implement a varying subset, which makes allowlists based on today's local terminal a poor choice.
OSC 8 links can turn innocent-looking text into a clickable destination. The visible label may say build report, while the target goes elsewhere. A human reading a cleaned agent transcript does not need a live link. Retain the label as ordinary text if you can parse it safely, or remove the whole OSC wrapper and preserve only subsequent printable bytes. Do not retain URI targets unless your product has a separate URL validation and display policy.
OSC 52 can ask a terminal to set clipboard content. Some terminals disable it or require a setting, but the collector cannot rely on that. If a raw log replays into a permissive terminal, the command can place attacker-chosen content on an operator's clipboard. The next paste may land in a shell, ticket, chat, or credential field.
Title-setting sequences create a quieter problem. They can alter the window title shown in a terminal multiplexer, operating-system task switcher, or recording. A title that resembles an approval request or a successful deployment can mislead an operator who scans many windows. Stripping all OSC traffic removes the whole class without maintaining a list that will age badly.
Do not try to preserve OSC strings for model comprehension. An agent has no legitimate need to execute a terminal title update, click a terminal hyperlink, or receive a clipboard operation. If a command result contains useful URL text, make the command print the URL as normal text or extract it from a structured response before it enters the terminal path.
The same rule applies to device-control strings and application program commands. Some emulators ignore them. Others add behavior over time. An output boundary should fail closed: if it recognizes a control-string introducer, consume through a valid terminator or through the configured maximum length, record the anomaly, and keep the payload out of normal context.
Define an output contract for every tool
A sanitizer cannot repair an output interface that asks a terminal transcript to carry a database, a report, and a user interface at once. Tools should state what they return to an agent: plain UTF-8 text, structured JSON produced on a non-terminal channel, or a reference to an isolated artifact. Make terminal-like output the exception rather than the default interchange format.
For commands you own, disable decoration when an agent calls them. Many programs offer a no-color flag, a machine-readable mode, or an environment setting. Prefer structured output only when you control its schema and bound its size. JSON can still contain prompt injection in string fields, so label every field as data and keep untrusted descriptions separate from instructions.
For commands you do not own, run them with pipes rather than a pseudo-terminal unless the command requires terminal behavior. A pseudo-terminal encourages progress bars, cursor rewrites, title controls, and different code paths. Pipes do not make output safe, but they reduce the amount of terminal protocol you must handle.
Record provenance with the result. An agent and reviewer should be able to see the executable invoked, the working directory, exit code, source stream, capture limit, and whether the sanitizer removed content. Do not let tool output impersonate those fields. Put collector-generated metadata in a distinct envelope outside the untrusted text.
A useful envelope can look like this:
{
"command": "test-runner --report plain",
"exit_code": 1,
"stdout": "142 tests passed\n",
"stderr": "fixture failed at tests/login.txt:18\n",
"sanitizer": {"removed_controls": 4, "truncated": false},
"raw_artifact": "restricted:sha256:..."
}
The raw_artifact field should be a reference that the ordinary agent cannot dereference. If you hand the content back on request without a new authorization decision, the reference was cosmetic.
Test hostile output outside your daily shell
A sanitizer that passes a unit test with green and red color codes has barely started. Test bytes that cross parser states, terminate unexpectedly, and interact with display controls. Run the corpus through the exact capture path that your agents use, including process spawning, buffering, log storage, and transcript rendering.
Start with a small corpus that contains an ESC byte at the end of one chunk and [ at the start of the next. Add CSI sequences with unusual parameter lengths, OSC strings terminated by both BEL and ST, an OSC string with no terminator, C1 CSI bytes, backspaces, repeated carriage returns, and invalid UTF-8 around an escape sequence. Assert that the agent-facing text contains no ESC, no C1 range bytes, and no payload from removed strings.
Then test display semantics. Feed a status line such as working 10%\rworking 20%\rfailure through your chosen carriage-return policy. If you preserve it as a newline, the agent sees the history. If you emulate overwriting, the agent sees only failure. Either policy can work, but document it and test it. Silent behavior changes during a parser update make incident review painful.
Fuzzing pays off here because escape grammars have small states and enormous malformed input space. Generate random byte streams with an emphasis on ESC, BEL, backslash, C1 bytes, and long unterminated strings. The properties are straightforward: the filter terminates within a time and memory budget, it does not throw, and its output has no prohibited control bytes.
Also test the human path. A log page, desktop notification, terminal pane, and copied transcript can each interpret content differently. Render the raw corpus only in a safe byte viewer. Render cleaned text in every normal interface. If an engineer can copy a raw log entry into an interactive terminal during a routine investigation, your isolation boundary has a hole.
Human approval does not sanitize a transcript
An approval prompt decides whether an agent may perform an action. It cannot decide whether the action's returned bytes are safe to display or include in a model prompt. Keep those controls separate, or a person will assume that approving ssh host command also endorsed every banner, file name, and remote error the host returns.
This distinction matters for action gateways. Sallyport can keep credentials out of an agent and require human authorization for an action, but any integration that sends action results into an agent still needs an output normalization boundary. Credential custody and transcript safety answer different questions.
Do not use repeated approval to compensate for weak output handling. A person asked to approve each read will not inspect every character in a long response, and the response may arrive after the approval anyway. Per-call review has a place for sensitive actions. It does not turn untrusted output into trusted context.
Keep the decision record beside the sanitized result. Record that the operator approved a process or call, then record the actual command result as untrusted tool data with the sanitizer report. That separation helps an incident review answer two questions without blending them: who authorized the operation, and what data did the operation return?
Raw capture must remain outside ordinary agent reach
A common escape hatch destroys an otherwise sound design: when the cleaned result seems incomplete, the agent gets a tool named read_raw_output. That tool converts a safety boundary into a speed bump. An attacker only needs to make the cleaned summary look confusing enough that the agent asks for the original.
Use constrained retrieval instead. Let a person open the artifact in a safe viewer. Let a dedicated extractor return a bounded hex range, a digest comparison, or printable text after it applies the same parser. If a workflow truly requires raw protocol inspection, require an explicit human decision and use a viewer that does not execute terminal controls.
Keep raw and cleaned retention policies distinct. Raw output may need shorter retention because it can contain secrets and hostile payloads. Cleaned transcripts may remain useful for agent session review, but they still hold business data. Hashes connect the two records without forcing routine users to retrieve the raw bytes.
The first implementation task is not a prompt rule that tells agents to ignore terminal instructions. Put a byte parser between process output and every agent-facing transcript, make unknown control syntax disappear, and preserve the original only where ordinary agents cannot fetch it. That change removes an entire class of ambiguity before a model, terminal, or tired operator has to reason about it.
FAQ
Are ANSI escape codes the only terminal control sequences I need to remove?
No. ANSI is a loose shorthand that usually means ECMA-48 control sequences, but terminal emulators also support private extensions. OSC commands, device-control strings, C1 controls, and emulator-specific sequences all belong in the review.
Can I keep terminal colors while protecting an AI agent?
Colors can remain if a trusted renderer applies them after the agent has processed plain text. Do not pass the original SGR sequences through merely to preserve appearance. The safe ordering is parse, remove controls, then optionally render a constrained presentation layer for a human.
Is base64-encoded command output safe for an agent to inspect?
Treat it as unsafe data until a parser has normalized it. Base64 prevents a terminal from interpreting the bytes during transit, but an agent can decode it and reproduce the original control stream. Keep raw data outside the agent context unless a specific investigation requires it.
Why does an escape sequence matter if the model does not have a terminal emulator?
A terminal emulator applies the controls, while an agent may read them as literal text, decoded tokens, or a cleaned transcript. Those are different consumers with different failure modes. Sanitization protects the agent's context; a safe viewer protects the human operator's terminal.
When should I strip output instead of quarantining it?
Strip by default when the agent needs status, diagnostics, and ordinary command results. Isolate when the original bytes matter for debugging, incident review, or a tool whose output cannot be reduced safely. Never use a raw terminal pane as the isolation mechanism.
Why is a regex not enough for removing terminal escapes?
Most regular expressions fail when a sequence spans chunks, uses C1 forms, contains an OSC payload, or ends with ST instead of BEL. A small state machine is easier to audit because it names the states and termination rules. Feed it bytes, not pre-decoded text.
Can terminal injection persist through agent transcripts or logs?
Yes, in a shared context it can mislead the next agent process as easily as the current one. Session logs need the same normalization rules as live tool output. Keep a separate protected raw record if investigators need byte fidelity.
Do human approval prompts prevent terminal-output prompt injection?
Approval proves that a person permitted an action; it does not make the returned bytes trustworthy. An approved command can still read hostile repository content, remote banners, package metadata, or log lines. Apply output handling after every approved action.
How should I retain raw command output for forensics?
Store raw bytes in an access-controlled artifact store or encrypted audit record, then give the agent only a reference and cleaned summary. Record the byte length, digest, parser version, and removal count with the artifact. That lets an investigator reconstruct what happened without handing hostile bytes to routine agent runs.
What test cases should an agent terminal sanitizer include?
Test fragmented sequences, OSC 8 links, OSC 52 clipboard requests, carriage-return rewrites, backspaces, C1 control bytes, invalid UTF-8, and unterminated strings. Run the same corpus through pipes and pseudo-terminals because tools often change behavior when they detect a TTY. A sanitizer that only handles a colorful compiler error has not earned trust.