PyRITpyrit 1.1.0 · Python 3.10+
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
21 small wins to finish your pathNext lesson

The assistant under test, as a PromptTarget

Lesson 4 built the database. This lesson builds the thing that gets attacked, and it is the piece that makes the whole course free to run.

PyRIT reaches whatever it is testing through one interface: a target. A target takes a conversation and returns messages. That is the entire contract, and it says nothing about models, keys or networks — so an ordinary Python class satisfies it completely.

Example
from pyrit.prompt_target import PromptTarget

print(sorted(PromptTarget.__abstractmethods__))

One method to write. Everything else — validation, loading the conversation history, recording the exchange — is done for you by the base class, which is why you implement _send_prompt_to_target_async and never touch send_prompt_async.

The smallest target that works

Example
from pyrit.models import Message
from pyrit.prompt_target import PromptTarget

class Echo(PromptTarget):
    async def _send_prompt_to_target_async(self, *, normalized_conversation):
        asked = normalized_conversation[-1].get_value()
        return [Message.from_prompt(prompt=f"you said: {asked}", role="assistant")]

print("built")

The last item in normalized_conversation is the message being sent now; the ones before it are history. get_value() pulls the text out of it, and Message.from_prompt wraps a reply as the assistant speaking.

The shop assistant

Now the real one. It is the function from lesson 1 with the base64 hole from lesson 2 left open on purpose, so that there is something in this course to find.

Example
import base64

STAFF_CODE = "SHOPTEST-42"

def decoded(text):
    try:
        return base64.b64decode(text, validate=True).decode()
    except Exception:
        return ""
Example
class ShopAssistant(PromptTarget):
    async def _send_prompt_to_target_async(self, *, normalized_conversation):
        asked = normalized_conversation[-1].get_value()
        plain = (asked + " " + decoded(asked)).lower()
        if "discount code" in plain:
            reply = f"The staff discount code is {STAFF_CODE}." if decoded(asked) \
                else "Sorry, I cannot share internal codes."
        elif "return" in plain:
            reply = "You can return anything within 30 days."
        else:
            reply = "I can help with orders, returns and delivery."
        return [Message.from_prompt(prompt=reply, role="assistant")]

print("the system under test is ready")

It refuses the plain question and answers the encoded one. A real model's version of this is subtler and nobody wrote it deliberately, but the shape is the same: the guard reads one form of the question and the answer is given to another.

A constructor has to take keywords

Give it a positional argument and the class will not even define. PyRIT checks this when the subclass is created, not when you build one.

Example
try:
    class Broken(PromptTarget):
        def __init__(self, patience):
            super().__init__()
        async def _send_prompt_to_target_async(self, *, normalized_conversation):
            return []
except TypeError as e:
    print(type(e).__name__, str(e)[:90])

The fix is a bare * in the signature. Every constructor in PyRIT is keyword-only, which is why every example reads ShopAssistant(patience=1) and never ShopAssistant(1).

Example
class ShopAssistant(PromptTarget):
    def __init__(self, *, patience=99, custom_configuration=None):
        super().__init__(custom_configuration=custom_configuration)
        self.patience = patience
        self.asked = 0

patience is how many refusals it manages before giving in, which lesson 28 needs and lessons 6 to 27 leave at its default. The finished file is pretend_pyrit.py, and every lesson from here imports it.

Try it yourself
  • Give Echo a second message in the returned list and see what memory does with it.
  • Remove the * from the constructor and read the full error.
  • Make the assistant refuse the base64 form too, then remember to put the hole back.

Every expert started right here.