BUG Console printers write ANSI/OSC sequences from target responses straight to the terminal
- Dominant language
- Python
- Stars
- 4.5k
- Forks
- 893
- Avg merge
- 3d 50m
- Merged PRs (30d)
- 165
Description
#### Describe the bug
The pretty printers in `pyrit.output` (conversation, attack result, score, scenario result) and `FuzzerResultPrinter` put text that the target under test controls into their output as-is: response text, original and converted prompt values, `partial_content` from blocked responses, reasoning summaries, score rationales, the objective, `outcome_reason` and metadata values. `_PrettyPrinterMixin._format_colored` wraps that text in PyRIT's own colour codes, and nothing escapes control characters inside it.
So when a target answers with escape sequences, the operator's terminal acts on them instead of displaying them. Getting a model to produce that answer is the goal of the `ansi_attack` technique, which is part of the default `EASY` aggregate of `foundry.red_team_agent`. `AnsiAttackConverter.LIVE_PAYLOADS` also go into the prompt raw, so printing an `ansi_attack` conversation replays the payload from the user turn even when the model refuses. The printing happens on the normal paths: `output_attack_async`, `output_conversation_async`, notebooks, and `pyrit_scan scenario-results --view conversations|full`.
PyRIT's own live payloads are enough to show the effect: `\x1b[3A\x1b[2K` moves the cursor up and erases a line that is already on screen (the outcome or score line just printed), `\x1b[8m` conceals text, an OSC 8 sequence renders a clickable link whose visible label differs from its URL, and OSC 0 changes the window title. Terminals configured to allow it also honour OSC 52 clipboard writes. The C1 one-character introducers `\x9b` / `\x9d` are in that payload list too, and `\r` lets a response overwrite what it just printed.
The target under test is untrusted by definition, so its text crosses a real trust boundary on its way to the operator's terminal. I am filing this as defence-in-depth hardening for the person running PyRIT, not as a vulnerability report: there is no Microsoft product vulnerability here, and the attack class is already public in PyRIT's own `AnsiAttackConverter` and in garak, where a maintainer asked for output sanitisation for the same reason (NVIDIA/garak#1975). I ran into it while working on the ANSI output scorers (#2683 / #2688) - once a scorer flags a raw hit, printing the result sends the payload back to the console.
#### Steps/Code to Reproduce
```python
import asyncio
from pyrit.converter import AnsiAttackConverter
from pyrit.models import Message, MessagePiece
from pyrit.output.conversation.pretty import PrettyConversationPrinter
class NoScores:
async def get_messages_async(self, *, conversation_id):
return []
async def get_scores_async(self, *, prompt_ids):
return []
async def main():
# OSC 8 hyperlink payload shipped with PyRIT's own ANSI converter
hyperlink = AnsiAttackConverter.LIVE_PAYLOADS[4]
reply = MessagePiece(role="assistant", original_value=f"Sure, here it is: {hyperlink}")
printer = PrettyConversationPrinter(source=NoScores(), enable_colors=False)
rendered = await printer.render_async([Message(message_pieces=[reply])])
print("raw escape sequence in output:", "\x1b]8;;" in rendered)
print(rendered)
asyncio.run(main())
```
No keys, no network, no database needed.
#### Expected Results
`raw escape sequence in output: False`, and the response line reads
```
Sure, here it is: \x1b]8;;https://learn.microsoft.com/en-us/security/ai-red-team/\x07Microsoft AI Red Team\x1b]8;;\x07
```
PyRIT's own colours keep working; memory, the database and the exports are unchanged.
#### Actual Results
`raw escape sequence in output: True`, and the terminal prints a clickable "Microsoft AI Red Team" link instead of the escape codes. The same happens with cursor-up/erase-line, `\x1b[8m` conceal, the C1 forms `\x9b` / `\x9d` and `\r`, with colours enabled or disabled, through the conversation printer, the attack-result summary, score rationales and `FuzzerResultPrinter`.
#### Proposed fix
- Add `escape_control_characters(text)` to `pyrit/common/text_helper.py`: replace C0 controls except `\t` and `\n`, plus DEL and the C1 range (which covers the single-character CSI/OSC introducers), with their `repr` form. Non-ASCII text is untouched. Not exported from `pyrit/common/__init__.py`, per the lazy-package contract.
- Call it from `_PrettyPrinterMixin._format_colored`. Every line the pretty printers emit goes through that one function, so one call covers the conversation, attack-result, score, scorer and scenario-result printers. Colours are added after escaping, so PyRIT's own formatting is unaffected. (Two appends in `score/pretty.py` currently bypass it - a constant label and the scorer class name - and are easy to route through it so the choke point has no exceptions.)
- Escape before wrapping in `PrettyConversationPrinter._render_wrapped_text` and for the score rationale, and treat `\r\n` as a newline there: the escaped form has to count toward the wrap width, `TextWrapper` otherwise drops a lone `\r` that lands at a wrap boundary, and a CRLF response would otherwise end every line with a literal `\r`.
- Use the same helper in `FuzzerResultPrinter`, in `_print_wrapped_text` as well as `_print_colored` - the former wraps first, and `textwrap.wrap`'s default `replace_whitespace=True` would otherwise turn a lone `\r` in a template into a space.
- Unit tests that push `AnsiAttackConverter.LIVE_PAYLOADS` through each printer with colours on and off and assert that nothing but PyRIT's own SGR codes reaches the output.
Display only: no new parameters, and memory, the database, the exports and the markdown printers stay as they are. Two follow-ups I would keep separate: logging (the INFO stdout handler, plus the full-response logs in `azure_ml_chat_target` and `executor/workflow/xpia`), and `pyrit/cli/_output.py`, which prints the retry/error lines and, at line 463, `f" objective: {attack_result.objective}"` raw on the very `scenario-results --view conversations` path above - #2508 is already editing that file, so I would rather not touch it here.
#### Two questions
1. **Escape notation.** With `repr`-style escapes a raw ESC renders exactly like a model that literally typed the four characters `\x1b`, which is the raw-vs-escaped distinction the scorers in #2688 make. Unicode control pictures (`␛[32m`) keep the two apart and preserve the line width, but C1 would still need a `\x9b`-style fallback. Do you have a preference? Happy to go either way.
2. **Scope: Unicode format characters.** The C0/DEL/C1 class does not cover the bidirectional controls, so the output of PyRIT's own `BidiConverter` (U+202E … U+202C, Trojan Source / CVE-2021-42574) still reaches the terminal and can reorder a displayed line. I left those alone on purpose: `repr` escapes the whole `Cf` category, which would mangle legitimate text (a family emoji becomes `emojiemoji`) and the isolates U+2066/U+2069 are the correct way to embed an LTR run in RTL text. Would you want a narrow set (the overrides/embeddings/isolates only) escaped as well, or is C0/C1 the right boundary? Unlike `ansi_attack`, `BidiConverter` is not registered as a foundry technique, so the exposure is narrower.
I would like to take this.
#### Versions
- OS: macOS 27.0 (any terminal that honours ANSI escapes; not macOS-specific)
- Python version: 3.12.13
- PyRIT version: installed from main branch in editable mode, at 2429881 (1.2.0.dev0)
- version of Python packages:
```
System:
python: 3.12.13 (main, May 4 2026, 21:02:19) [Clang 22.1.3 ]
executable: .../.venv/bin/python3
machine: macOS-27.0-arm64-arm-64bit
Python dependencies:
pyrit: 1.2.0.dev0
Cython: None
numpy: 2.2.6
openai: 2.54.0
packaging: 25.0
pip: None
scipy: 1.16.3
setuptools: 83.0.0
sqlite3: None
torch: 2.14.0
transformers: 5.16.1
```
Contributor guide
No contributing guide indexed for this repository
Research direction
Start with pyrit/common/text_helper.py, _PrettyPrinterMixin._format_colored, PrettyConversationPrinter._render_wrapped_text, score/pretty.py, and FuzzerResultPrinter. Trace the normal output paths and run the existing printer tests with AnsiAttackConverter.LIVE_PAYLOADS, both with and without colours. Done means control sequences are escaped in the named printers while PyRIT's own colours, memory, database, exports, and markdown printers remain unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- security, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100