Target

A target is the AI system under test, wrapped so the framework can drive it: a chatbot, a tool-using agent, a sandboxed application. A target is a passive attack surface, not an adversary. Its one job is to expose, faithfully and in full, everything an attacker might touch or observe, and then to run.

You write a target by subclassing Target and implementing its surfaces and lifecycle. The guiding principle is to keep a target general and reusable: wrap “any LLM” or “the AgentDojo agent,” and leave benchmark-specific goals and judging to the Task and SecurityClaim.

The five surfaces

A target exposes its system through five kinds of surface. Two are for setup and evaluation (a task and an evaluator use them, off the event loop); two are the attacker-facing surfaces; one is the trust-boundary map.

Surface Who uses it When
Config (config_specs / set_config) the Task before a run
Query (query_specs / query) the evaluator after a run
Controllables (get_controllables) the Optimizer during a run (inject)
Observables (get_observables) the Optimizer before a run (read static context)
Security domain (security_domain) the Controller to filter by scope

Each piece of information is exposed exactly once. A tool call the attacker can tamper with is a controllable (never also an observable); a fact the attacker only reads is an observable; a live event during a run goes on the trajectory as an ObservableEvent, not into get_observables. Static facts about the setup belong to get_observables; the live trace belongs on the trajectory.

Manual values (constructor)

API keys, credentials, and other user-provided secrets are passed directly to the constructor, not through any framework surface. This keeps the interface clean and instantiation explicit:

target = MyDockerTarget(api_key="sk-...", image="my-app:latest")

A target runs inference with its own provider client, not the optimizer’s LLMClient, so its own token spend is out of band and uncounted against the attacker’s budget.

Pre-run configuration (task-set)

A task uses these to set up the scenario (plant a secret, set a benign user goal). A config slot is never an attacker surface: only the task sets it. Model identity and generation settings stay out of config; they are construction concerns, fixed for an experiment.

Post-run queries (evaluator-used)

Config and query are intentionally distinct: config is task-set pre-run state; query is post-run ground truth, which may differ from what was configured.

Attacker-facing surfaces

The Controller filters both by scope before the optimizer sees them, so a target always returns its full set and lets the threat model decide what is visible.

Security domain

Execution and the state lifecycle

A target has three state lifetimes, and getting them right is what makes multi-run and cross-run attacks work:

The instance lifecycle the Controller drives:

construct
configure_target()
reset_ephemeral_state()
teardown()
discarded
×N runs run() → evaluate() → reset_ephemeral_state()
Ephemeral · reset every run
Durable survives resets · gone when the next task gets a fresh instance
Resources / identity clients & external handles · released in teardown()
The reset ticks in Ephemeral are the per-run resets. Durable runs unbroken through them: it survives every reset_ephemeral_state(), and is discarded only when the next task gets a fresh instance.

reset_ephemeral_state() must be implemented even if it is a no-op: making it explicit forces the author to decide, per field, what is ephemeral and what survives.

Internal parallelism and thread bridging

A target may run inference concurrently. It may spawn branches that each call send_event independently (each suspends only its own branch), and a thread-backed target (Docker, a subprocess) may bridge blocking work back to the loop with asyncio.run_coroutine_threadsafe. Target instances carry no concurrency cap of their own; the TargetFactory decides how many run in parallel. The patterns and examples are in Events, Channel & Trajectory.

A minimal shape

class MyChatTarget(Target):
    def __init__(self, api_key: str, model: str) -> None:
        self._api_key, self._model = api_key, model
        self._system_prompt = ""
        self._last_response = ""

    @property
    def config_specs(self):
        return [ConfigSpec("system_prompt", SYSTEM_TAG, "The system prompt")]

    def set_config(self, name, value):
        if name == "system_prompt":
            self._system_prompt = value

    @property
    def query_specs(self):
        return [QuerySpec("last_response", "The model's most recent reply")]

    def query(self, name, **params):
        return self._last_response

    @property
    def security_domain(self):
        return SecurityDomain([SYSTEM_TAG, USER_TAG])

    def get_controllables(self):
        return [Controllable("chat_message", USER_TAG, "The user's message")]

    def get_observables(self):
        return [ObservableValue(Observable("model", SYSTEM_TAG, "Model id"), self._model)]

    async def run(self, emit, send_event):
        pre = ControllablePreCallEvent(
            controllable=self.get_controllables()[0], request="",
        )
        resp = await send_event(pre)                       # ask the attacker
        message = resp.value if isinstance(resp, ControllableInjection) else ""
        emit(ObservableEvent(observable=..., content=message))
        self._last_response = await self._call_model(self._system_prompt, message)
        emit(ObservableEvent(observable=..., content=self._last_response))

    async def reset_ephemeral_state(self):
        self._last_response = ""                            # ephemeral only

    async def teardown(self):
        ...                                                 # close clients, etc.

Design decisions