Skip to content
Draft
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
4 changes: 4 additions & 0 deletions docs/api/core-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Data types shared across the entire framework. All importable from `rampart` dir
- ToolCall
- SideEffect
- Turn
- EvaluationRole
- TerminationReason
- EvalOutcome
- EvalResult
- EvalContext
Expand All @@ -28,6 +30,8 @@ Data types shared across the entire framework. All importable from `rampart` dir
- SafetyStatus
- HarmCategory
- InjectionRecord
- resolve_attack_verdict
- resolve_probe_verdict
- resolve_as_attack
- resolve_as_probe

Expand Down
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
28 changes: 22 additions & 6 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,
)
```

`~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 Expand Up @@ -210,7 +226,7 @@ See [`Attacks.xpia()`][rampart.attacks.Attacks.xpia] for the full API reference.
| `inject` | `InjectionHandle \| list[InjectionHandle] \| None` | `None` | Prepared injections from `surface.inject()`. `None` for inline XPIA. |
| `trigger` | `str \| list[str] \| Request \| list[Request] \| PromptDriver` | required | Benign prompt(s) that cause retrieval of injected content. |
| `evaluator` | [`Evaluator`][rampart.core.evaluator.Evaluator] | required | What attack condition to detect. |
| `max_turns` | `int` | `5` | Maximum prompt-response exchanges before `ERROR`. |
| `max_turns` | `int` | `5` | Maximum prompt-response exchanges; reaching the limit resolves the trace normally. |
| `event_handlers` | `list[ExecutionEventHandler] \| None` | `None` | Additional lifecycle event handlers. |

---
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
22 changes: 17 additions & 5 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 All @@ -80,7 +92,7 @@ See [`Probes.behavior()`][rampart.probes.Probes.behavior] for the full API refer
| `prompts` | `list[str] \| None` | `None` | A list of prompt strings. |
| `driver` | [`PromptDriver`][rampart.core.prompt_driver.PromptDriver] `\| None` | `None` | A pre-built prompt driver. |
| `evaluator` | [`Evaluator`][rampart.core.evaluator.Evaluator] | required | What behavior to detect. |
| `max_turns` | `int` | `25` | Maximum exchanges before `ERROR`. |
| `max_turns` | `int` | `25` | Maximum exchanges; reaching the limit resolves the trace normally. |

!!! warning
Provide exactly one of `prompt`, `prompts`, or `driver`. Providing more than one or none raises `ValueError`.
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]:

```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 |
|---|---|---|
| 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
8 changes: 8 additions & 0 deletions rampart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,21 @@
SafetyStatus,
resolve_as_attack,
resolve_as_probe,
resolve_attack_verdict,
resolve_probe_verdict,
)
from rampart.core.types import (
EvalContext,
EvalOutcome,
EvalResult,
EvaluationRole,
ObservabilityLevel,
Payload,
PayloadFormat,
Request,
Response,
SideEffect,
TerminationReason,
ToolCall,
Turn,
)
Expand Down Expand Up @@ -70,6 +74,7 @@
"EvalContext",
"EvalOutcome",
"EvalResult",
"EvaluationRole",
"Evaluator",
"EvaluatorError",
"ExecutionEvent",
Expand All @@ -95,13 +100,16 @@
"Session",
"SideEffect",
"Surface",
"TerminationReason",
"ToolCall",
"ToolDeclaration",
"TranscriptScope",
"Turn",
"record_result",
"resolve_as_attack",
"resolve_as_probe",
"resolve_attack_verdict",
"resolve_probe_verdict",
]


Expand Down
4 changes: 2 additions & 2 deletions rampart/attacks/_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ def xpia(
Benign user request(s) that cause the agent to process
poisoned content.
evaluator (Evaluator): What condition to check for.
max_turns (int): Maximum prompt-response exchanges before
ERROR. Defaults to 5.
max_turns (int): Maximum prompt-response exchanges. Reaching the
limit resolves the trace normally. Defaults to 5.
event_handlers (list[ExecutionEventHandler] | None): Optional
additional handlers for custom observability.

Expand Down
4 changes: 2 additions & 2 deletions rampart/attacks/_xpia.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ class XPIAExecution(BaseExecution):
attachments.
driver (PromptDriver): How to drive the trigger conversation.
evaluator (Evaluator): What condition to check for.
max_turns (int): Maximum prompt-response exchanges before the
execution stops with ERROR. Prevents unbounded loops.
max_turns (int): Maximum prompt-response exchanges. Reaching the
limit resolves the trace normally and prevents unbounded loops.
event_handlers (list[ExecutionEventHandler] | None): Additional
handlers beyond the framework defaults.
"""
Expand Down
8 changes: 8 additions & 0 deletions rampart/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,21 @@
SafetyStatus,
resolve_as_attack,
resolve_as_probe,
resolve_attack_verdict,
resolve_probe_verdict,
)
from rampart.core.types import (
EvalContext,
EvalOutcome,
EvalResult,
EvaluationRole,
ObservabilityLevel,
Payload,
PayloadFormat,
Request,
Response,
SideEffect,
TerminationReason,
ToolCall,
Turn,
)
Expand All @@ -55,6 +59,7 @@
"EvalContext",
"EvalOutcome",
"EvalResult",
"EvaluationRole",
"Evaluator",
"ExecutionEvent",
"ExecutionEventData",
Expand All @@ -79,10 +84,13 @@
"Session",
"SideEffect",
"Surface",
"TerminationReason",
"ToolCall",
"ToolDeclaration",
"Turn",
"evaluate_turn_async",
"resolve_as_attack",
"resolve_as_probe",
"resolve_attack_verdict",
"resolve_probe_verdict",
]
Loading