Skip to content

BUG Console printers write ANSI/OSC sequences from target responses straight to the terminal #2693

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

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 FEAT: Add ANSI escape output scorers #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 emoji‍emoji) 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions