You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
importasynciofrompyrit.converterimportAnsiAttackConverterfrompyrit.modelsimportMessage, MessagePiecefrompyrit.output.conversation.prettyimportPrettyConversationPrinterclassNoScores:
asyncdefget_messages_async(self, *, conversation_id):
return []
asyncdefget_scores_async(self, *, prompt_ids):
return []
asyncdefmain():
# OSC 8 hyperlink payload shipped with PyRIT's own ANSI converterhyperlink=AnsiAttackConverter.LIVE_PAYLOADS[4]
reply=MessagePiece(role="assistant", original_value=f"Sure, here it is: {hyperlink}")
printer=PrettyConversationPrinter(source=NoScores(), enable_colors=False)
rendered=awaitprinter.render_async([Message(message_pieces=[reply])])
print("raw escape sequence in output:", "\x1b]8;;"inrendered)
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
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.
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)
Describe the bug
The pretty printers in
pyrit.output(conversation, attack result, score, scenario result) andFuzzerResultPrinterput text that the target under test controls into their output as-is: response text, original and converted prompt values,partial_contentfrom blocked responses, reasoning summaries, score rationales, the objective,outcome_reasonand metadata values._PrettyPrinterMixin._format_coloredwraps 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_attacktechnique, which is part of the defaultEASYaggregate offoundry.red_team_agent.AnsiAttackConverter.LIVE_PAYLOADSalso go into the prompt raw, so printing anansi_attackconversation 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, andpyrit_scan scenario-results --view conversations|full.PyRIT's own live payloads are enough to show the effect:
\x1b[3A\x1b[2Kmoves the cursor up and erases a line that is already on screen (the outcome or score line just printed),\x1b[8mconceals 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/\x9dare in that payload list too, and\rlets 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
AnsiAttackConverterand 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
No keys, no network, no database needed.
Expected Results
raw escape sequence in output: False, and the response line readsPyRIT'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[8mconceal, the C1 forms\x9b/\x9dand\r, with colours enabled or disabled, through the conversation printer, the attack-result summary, score rationales andFuzzerResultPrinter.Proposed fix
escape_control_characters(text)topyrit/common/text_helper.py: replace C0 controls except\tand\n, plus DEL and the C1 range (which covers the single-character CSI/OSC introducers), with theirreprform. Non-ASCII text is untouched. Not exported frompyrit/common/__init__.py, per the lazy-package contract._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 inscore/pretty.pycurrently bypass it - a constant label and the scorer class name - and are easy to route through it so the choke point has no exceptions.)PrettyConversationPrinter._render_wrapped_textand for the score rationale, and treat\r\nas a newline there: the escaped form has to count toward the wrap width,TextWrapperotherwise drops a lone\rthat lands at a wrap boundary, and a CRLF response would otherwise end every line with a literal\r.FuzzerResultPrinter, in_print_wrapped_textas well as_print_colored- the former wraps first, andtextwrap.wrap's defaultreplace_whitespace=Truewould otherwise turn a lone\rin a template into a space.AnsiAttackConverter.LIVE_PAYLOADSthrough 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_targetandexecutor/workflow/xpia), andpyrit/cli/_output.py, which prints the retry/error lines and, at line 463,f" objective: {attack_result.objective}"raw on the veryscenario-results --view conversationspath above - #2508 is already editing that file, so I would rather not touch it here.Two questions
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.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:represcapes the wholeCfcategory, which would mangle legitimate text (a family emoji becomesemojiemoji) 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? Unlikeansi_attack,BidiConverteris not registered as a foundry technique, so the exposure is narrower.I would like to take this.
Versions