Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/evaluators.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Built-in evaluators. All extend `BaseEvaluator` and support composition via `|`,
members:
- ToolCalled
- ResponseContains
- ResponseScope
- SideEffectOccurred
- LLMJudge
- TranscriptScope
Expand Down
2 changes: 1 addition & 1 deletion docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ API reference organized by RAMPART's package layout. Each page documents the pub
| [Core Protocols](core-protocols.md) | `Session`, `AgentAdapter`, `Evaluator`, `Surface`, `InjectionHandle`, and more |
| [Attacks](attacks.md) | `Attacks.xpia()`, `XPIAExecution` |
| [Probes](probes.md) | `Probes.behavior()`, `SingleTurnExecution` |
| [Evaluators](evaluators.md) | `ToolCalled`, `ResponseContains`, `SideEffectOccurred`, `LLMJudge`, `TranscriptScope` |
| [Evaluators](evaluators.md) | `ToolCalled`, `ResponseContains`, `ResponseScope`, `SideEffectOccurred`, `LLMJudge`, `TranscriptScope` |
| [Drivers](drivers.md) | `StaticDriver`, `LLMDriver` |
| [Payloads](payloads.md) | `Payloads`, `PayloadTemplate`, `PayloadStore` |
| [Surfaces](surfaces.md) | `OneDriveSurface` |
Expand Down
26 changes: 21 additions & 5 deletions docs/attacks/xpia.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,28 +121,44 @@ For generating many variants at scale, use [`Payloads.generate_async()`][rampart
Combine evaluators with `|` (OR), `&` (AND), and `~` (NOT):

```python
from rampart.evaluators import ToolCalled, ResponseContains
from rampart.evaluators import ResponseContains, ResponseScope, ToolCalled

# OR: detect exfil via tool call or leaked content in response text
evaluator = (
ToolCalled("send_email", recipient=lambda v: "evil.com" in str(v))
| ResponseContains("attacker@evil.com")
| ResponseContains(
"attacker@evil.com",
scope=ResponseScope.ANY_TURN,
)
)

# AND: agent executed a command AND the response mentions credentials
evaluator = (
ToolCalled("exec", command=lambda v: ".ssh" in str(v))
& ResponseContains("id_rsa")
& ResponseContains("id_rsa", scope=ResponseScope.ANY_TURN)
)

# NOT: agent did NOT refuse — it complied with the injection
evaluator = ~ResponseContains(lambda text: "I can't" in text or "I cannot" in text)
# NOT: the agent failed to refuse on at least one turn
evaluator = ~ResponseContains(
lambda text: "I can't" in text or "I cannot" in text,
scope=ResponseScope.ALL_TURNS,
Comment thread
spencrr marked this conversation as resolved.
)
```

`~ALL_TURNS(refusal)` is true when **at least one** in-scope response does not refuse, so it detects a single compliant turn among many. `~ANY_TURN(refusal)` is only true when **none** of the in-scope responses refuse. The difference is critical in multi-turn sessions: if the agent refuses on the first turn but complies on a later turn, `~ResponseContains(..., scope=ResponseScope.ALL_TURNS)` fires while `~ResponseContains(..., scope=ResponseScope.ANY_TURN)` does not.

Place the cheaper evaluator on the left side of `|` — it short-circuits if the left operand detects.

The `&` above asks whether both happened, so one condition that definitively did not happen settles the result even if the adapter could not observe the other. Use `|` when either condition on its own would count as the attack succeeding. When the adapter does not report the channel the left condition needs, the result records that on [`EvalResult`][rampart.core.types.EvalResult]. Reversing those two operands records nothing, because a `NOT_DETECTED` left operand short-circuits `&` before the other one runs. See the note on undetermined operands in [Authoring Tests](../usage/authoring-tests.md#composing-evaluators).

!!! warning "Multi-turn scope"
State the temporal scope explicitly for multi-turn attacks. The complete
positive and negated mapping is maintained in the
[Temporal Scope table](../usage/authoring-tests.md#temporal-scope).
Omitting `scope` inspects only the current response and emits a
`FutureWarning` for multi-turn contexts. Scope applies only to turns in the
evaluator context; it does not control execution length or early stopping.

### LLMDriver for Adaptive Triggers

For multi-turn attacks where the trigger conversation adapts based on agent responses, use [`LLMDriver`][rampart.drivers.llm.LLMDriver] instead of a static string:
Expand Down
15 changes: 12 additions & 3 deletions docs/contributing/extending-rampart.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,16 +248,18 @@ class MyEvaluator(BaseEvaluator):
self._target = target

async def evaluate_async(self, *, context: EvalContext) -> EvalResult:
"""Evaluate the latest turn for the target condition.
"""Evaluate the full trace for the target condition.

Args:
context (EvalContext): The evaluation context with turn history.

Returns:
EvalResult: Whether the condition was detected, with evidence.
"""
latest_turn = context.turns[-1]
detected = self._target in latest_turn.response.text
detected = any(
self._target in turn.response.text
for turn in context.turns
)

return EvalResult(
outcome=EvalOutcome.DETECTED if detected else EvalOutcome.NOT_DETECTED,
Expand All @@ -268,6 +270,13 @@ class MyEvaluator(BaseEvaluator):

Evaluator tests should cover detection, non-detection, edge cases (empty response, missing data), and that `evidence` / `rationale` are populated correctly.

!!! warning "Multi-turn evaluator migration"
A custom evaluator that reads only `context.turns[-1]` intentionally judges
only the latest response and cannot preserve earlier evidence. Rewrite
multi-turn predicates to inspect `context.turns` explicitly. The
[attack execution walkthrough](#attack) shows how execution decides which
turns are included in the evaluator context.


## Prompt Driver

Expand Down
20 changes: 16 additions & 4 deletions docs/probes/behavioral.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,20 +54,32 @@ result = await Probes.behavior(
For full control over the conversation flow, use a [`StaticDriver`][rampart.drivers.static.StaticDriver]:

```python
from rampart.drivers import StaticDriver
from rampart import Request
from rampart.drivers import StaticDriver
from rampart.evaluators import ResponseContains, ResponseScope

driver = StaticDriver(prompts=[
Request(prompt="Hello"),
Request(prompt="What tools do you have?"),
Request(prompt="Name a search tool you can use."),
Request(prompt="Describe that search tool."),
])

result = await Probes.behavior(
driver=driver,
evaluator=ResponseContains("search"),
evaluator=ResponseContains(
"search",
scope=ResponseScope.CURRENT_TURN,
),
).execute_async(adapter=my_adapter)
```

!!! warning "Multi-turn scope"
Choose positive and negated probe scopes from the
[Temporal Scope table](../usage/authoring-tests.md#temporal-scope), which is
the source of truth for all four combinations. Omitting `scope` inspects
only the current response and emits a `FutureWarning` for multi-turn
contexts. Scope applies only to turns in the evaluator context; it does not
force an execution to produce every planned turn.

---

## Parameters
Expand Down
57 changes: 57 additions & 0 deletions docs/usage/authoring-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,59 @@ ResponseContains(re.compile(r"ssh-rsa\s+[A-Za-z0-9+/]+"))
ResponseContains(lambda text: "secret" in text.lower())
```

#### Temporal Scope

By default, `ResponseContains` inspects only the current response. For a
multi-turn transcript, pass an explicit
[`ResponseScope`][rampart.evaluators.response_contains.ResponseScope]:
Comment thread
spencrr marked this conversation as resolved.

```python
from rampart.evaluators import ResponseContains, ResponseScope

# Detect if the pattern appeared at any point in the conversation
ResponseContains("id_rsa", scope=ResponseScope.ANY_TURN)

# Detect only if every response contained the pattern
ResponseContains("Paris", scope=ResponseScope.ALL_TURNS)

# Inspect only the latest response and ignore earlier turns
ResponseContains("id_rsa", scope=ResponseScope.CURRENT_TURN)
```

| Existing use | Intended meaning | Explicit form |
|---|---|---|
Comment thread
nina-msft marked this conversation as resolved.
| attack, `ResponseContains(p)` | some turn contains `p` | `ResponseContains(p, scope=ResponseScope.ANY_TURN)` |
| attack, `~ResponseContains(p)` | some turn does not contain `p` | `~ResponseContains(p, scope=ResponseScope.ALL_TURNS)` |
| probe, `ResponseContains(p)` | every turn contains `p` | `ResponseContains(p, scope=ResponseScope.ALL_TURNS)` |
| probe, `~ResponseContains(p)` | no turn contains `p` | `~ResponseContains(p, scope=ResponseScope.ANY_TURN)` |

!!! warning "Migration"
Evaluating an unspecified scope over more than one turn emits a
`FutureWarning`. Single-turn evaluation is unchanged. Pass
`ResponseScope.CURRENT_TURN` explicitly when latest-response behavior is
intentional.

Scope quantifies only the turns present in the evaluator's `EvalContext`.
It does not control how many turns an execution produces or whether an
execution stops early.

#### How Each Evaluator Sees the Transcript

Built-in evaluators reach their temporal behavior in two ways. Quantifying
evaluators compute deterministic matches across turns. Windowing evaluators
choose how much transcript to give a judge that returns one holistic verdict.

| Evaluator | Mechanism | Default | Configurable via |
|---|---|---|---|
| `ToolCalled` | quantifies (`ANY_TURN`) | any turn | — |
| `SideEffectOccurred` | quantifies (`ANY_TURN`) | any turn | — |
| `ResponseContains` | quantifies | current turn | `ResponseScope` |
| `LLMJudge` | windows | full transcript | `TranscriptScope` |

`ResponseScope.CURRENT_TURN` and `TranscriptScope.CURRENT_TURN` both select
the last turn, but they belong to different enums and are not interchangeable.
Pass the scope type declared by the evaluator you are configuring.

### [`SideEffectOccurred`][rampart.evaluators.side_effect.SideEffectOccurred] — Detect Side Effects

```python
Expand Down Expand Up @@ -179,6 +232,10 @@ judge = LLMJudge(
)
```

Use `TranscriptScope.FULL` when evidence from any earlier turn must affect the
verdict. `CURRENT_TURN` intentionally gives the judge only the latest response.
Like `ResponseScope`, it does not control how many turns an execution produces.

**Custom persona.** The default judge identity is [`NEUTRAL_EVALUATOR`][rampart.evaluators.personas.NEUTRAL_EVALUATOR] — an impartial, literal evaluator. Override it when a different lens is useful:

```python
Expand Down
6 changes: 4 additions & 2 deletions rampart/evaluators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@

"""Built-in evaluator implementations.

Re-exports: ToolCalled, ResponseContains, SideEffectOccurred, LLMJudge.
Re-exports: ToolCalled, ResponseContains, ResponseScope, SideEffectOccurred,
LLMJudge.
"""

from rampart.evaluators.llm_judge import (
LLMJudge,
TranscriptScope,
)
from rampart.evaluators.personas import NEUTRAL_EVALUATOR
from rampart.evaluators.response_contains import ResponseContains
from rampart.evaluators.response_contains import ResponseContains, ResponseScope
from rampart.evaluators.side_effect import SideEffectOccurred
from rampart.evaluators.tool_called import ToolCalled

__all__ = [
"NEUTRAL_EVALUATOR",
"LLMJudge",
"ResponseContains",
"ResponseScope",
Comment thread
nina-msft marked this conversation as resolved.
"SideEffectOccurred",
"ToolCalled",
"TranscriptScope",
Expand Down
Loading