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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ jobs:
- name: Ruff format check
run: uv run ruff format --check .

- name: Pyright
run: uv run pyright
- name: ty
run: uv run ty check

test:
name: Test (Python ${{ matrix.python-version }})
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ repos:
- id: ruff-format
- repo: local
hooks:
- id: pyright
name: pyright
entry: uv run pyright
- id: ty
name: ty
entry: uv run ty check
language: system
types: [python]
pass_filenames: false
2 changes: 1 addition & 1 deletion docs/contributing/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ The `rampart/` source tree is organized by concern: foundational types live in `
RAMPART uses `@runtime_checkable` protocols for extension points that consumers implement (`AgentAdapter`, `Session`, `Evaluator`, `Surface`, `PromptDriver`). This means:

- **No inheritance required** — any class with the right methods satisfies the protocol
- **Type-checked at development time** by Pyright in strict mode
- **Type-checked at development time** by [ty](https://github.com/astral-sh/ty)
- **Verifiable at runtime** with `isinstance` checks

`BaseExecution` is the exception — it's an ABC because it owns the lifecycle skeleton and subclasses share real implementation.
Expand Down
8 changes: 4 additions & 4 deletions docs/contributing/code-style.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ RAMPART enforces a consistent code style through automated tooling and documente
| Tool | Purpose | Config location |
|------|---------|-----------------|
| [Ruff](https://docs.astral.sh/ruff/) | Linting and formatting | `pyproject.toml` `[tool.ruff.*]` |
| [Pyright](https://github.com/microsoft/pyright) | Static type checking (strict mode) | `pyproject.toml` `[tool.pyright]` |
| [ty](https://github.com/astral-sh/ty) | Static type checking | `pyproject.toml` `[tool.ty]` |
Comment thread
spencrr marked this conversation as resolved.
| [pre-commit](https://pre-commit.com/) | Git hooks for automated checks | `.pre-commit-config.yaml` |

### Running Checks

Pre-commit is the primary entry point — it runs Ruff (lint + format) and Pyright in one command:
Pre-commit is the primary entry point — it runs Ruff (lint + format) and ty in one command:

```bash
# Install the Git hook once (optional, runs on every commit)
Expand All @@ -32,7 +32,7 @@ uv run ruff format .
A few details worth knowing:

- **Ruff** is configured with `select = ["ALL"]`. Test files have relaxed rules (no docstrings, no type annotations, magic values allowed) via `per-file-ignores` in `pyproject.toml`.
- **Pyright** runs in **strict mode** targeting Python 3.11 — every function needs complete parameter and return type annotations.
- **ty** targets Python 3.11 — every function needs complete parameter and return type annotations.


## Key Conventions
Expand Down Expand Up @@ -157,7 +157,7 @@ Before committing, run pre-commit — it covers everything the automated tooling
uv run pre-commit run --all-files
```

This runs Ruff (linting + formatting) and Pyright (strict type checking), which together enforce the copyright header, type annotations, log formatting, import organization, and most other conventions on this page.
This runs Ruff (linting + formatting) and ty (type checking), which together enforce the copyright header, type annotations, log formatting, import organization, and most other conventions on this page.

A few rules are **not** caught by tooling and still need a human eye:

Expand Down
6 changes: 3 additions & 3 deletions docs/contributing/development-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Install the project dependencies using uv:
uv sync
```

`uv sync` installs the project in editable mode and includes the default `dev` group from `pyproject.toml` — ruff, pyright, pytest-cov, pytest-xdist, and pre-commit — into a virtual environment managed by uv.
`uv sync` installs the project in editable mode and includes the default `dev` group from `pyproject.toml` — ruff, ty, pytest-cov, pytest-xdist, and pre-commit — into a virtual environment managed by uv.

If you also plan to build the documentation locally, include the `docs` group:

Expand All @@ -76,7 +76,7 @@ The `pre-commit` tool itself is already installed via `uv sync`. The steps below

### (Optional) Install the Git hook

To have Ruff and Pyright run automatically on every `git commit`, install the pre-commit Git hook into `.git/hooks/pre-commit`:
To have Ruff and ty run automatically on every `git commit`, install the pre-commit Git hook into `.git/hooks/pre-commit`:

```bash
uv run pre-commit install
Expand Down Expand Up @@ -163,6 +163,6 @@ uv run mkdocs serve --strict

## (Recommended) VSCode IDE Setup

RAMPART uses strict Pyright type checking (`typeCheckingMode = "strict"` in `pyproject.toml`). For the best editor experience in VS Code, install the [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) extension — it picks up the project's Pyright settings automatically.
RAMPART uses [ty](https://github.com/astral-sh/ty) for static type checking (configured under `[tool.ty]` in `pyproject.toml`). For the best editor experience in VS Code, install the [Pylance](https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance) extension — its inference is broadly compatible with ty's. A dedicated ty language-server is also available from Astral if you prefer to mirror CI's checker exactly.

The repo also includes an `.editorconfig` file for consistent formatting across editors.
2 changes: 1 addition & 1 deletion docs/contributing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Check out our [GitHub issues](https://github.com/microsoft/RAMPART/issues) with

- :material-format-paint:{ .lg .middle } **[Code Style & Linting](code-style.md)**

Ruff, Pyright, pre-commit hooks, and naming conventions.
Ruff, ty, pre-commit hooks, and naming conventions.

- :material-test-tube:{ .lg .middle } **[Testing Standards](testing.md)**

Expand Down
2 changes: 1 addition & 1 deletion docs/contributing/pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ All of the following must pass before a PR can be merged:

- **Ruff check** — all lint rules pass
- **Ruff format** — code is properly formatted
- **Pyright** — strict type checking passes
- **ty** — type checking passes (`uv run ty check`)

### Tests

Expand Down
18 changes: 7 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ onedrive = [
[dependency-groups]
dev = [
"pre-commit>=4.5.1",
"pyright>=1.1.390",
"pytest-cov>=6.1.0",
"pytest-xdist[psutil]>=3.8.0",
"ruff>=0.15.10",
"ty>=0.0.49",
]
docs = [
"mkdocs>=1.6",
Expand Down Expand Up @@ -81,16 +81,6 @@ fail_under = 80
show_missing = true
skip_empty = true

[tool.pyright]
pythonVersion = "3.11"
typeCheckingMode = "strict"
include = ["rampart", "tests"]

[[tool.pyright.executionEnvironments]]
root = "tests"
extraPaths = ["."]
reportPrivateUsage = false

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
Expand Down Expand Up @@ -136,5 +126,11 @@ convention = "google"
[tool.ruff.lint.pylint]
max-args = 10

[tool.ty.environment]
python-version = "3.11"

[tool.ty.src]
include = ["rampart", "tests"]

[tool.uv.sources]
pyrit = { git = "https://github.com/microsoft/PyRIT", rev = "6dc8b94139757390286bbce7d53c1f7e58e66e29" } # v0.13.0
10 changes: 5 additions & 5 deletions rampart/attacks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
from typing import TYPE_CHECKING

from rampart.attacks._xpia import XPIAExecution
from rampart.core.injection import InjectionHandle
from rampart.drivers._utils import coerce_driver

if TYPE_CHECKING:
from rampart.core.evaluator import Evaluator
from rampart.core.execution import BaseExecution, ExecutionEventHandler
from rampart.core.injection import InjectionHandle
from rampart.core.prompt_driver import PromptDriver
from rampart.core.types import Request

Expand Down Expand Up @@ -89,11 +89,11 @@ def xpia(
``execute_async(adapter=...)``.
"""
if inject is None:
handles = []
elif isinstance(inject, list):
handles = inject
else:
handles: list[InjectionHandle] = []
elif isinstance(inject, InjectionHandle):
handles = [inject]
else:
handles = inject
driver = coerce_driver(trigger)

return XPIAExecution(
Expand Down
8 changes: 5 additions & 3 deletions rampart/core/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from __future__ import annotations

from contextlib import AbstractAsyncContextManager
from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable

if TYPE_CHECKING:
Expand All @@ -19,7 +20,7 @@


@runtime_checkable
class Session(Protocol):
class Session(AbstractAsyncContextManager["Session"], Protocol):
Comment thread
spencrr marked this conversation as resolved.
"""A bounded unit of interaction with the agent.

Sessions are async context managers. Entering returns the session
Expand Down Expand Up @@ -52,8 +53,9 @@ async def __aenter__(self) -> Self:
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: types.TracebackType | None,
exc_value: BaseException | None,
traceback: types.TracebackType | None,
/,
) -> None:
"""Clean up session resources. Must be idempotent."""
...
Expand Down
8 changes: 5 additions & 3 deletions rampart/core/injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from __future__ import annotations

import asyncio
from contextlib import AbstractAsyncContextManager
from typing import TYPE_CHECKING, Protocol, Self, runtime_checkable

if TYPE_CHECKING:
Expand All @@ -22,7 +23,7 @@


@runtime_checkable
class InjectionHandle(Protocol):
class InjectionHandle(AbstractAsyncContextManager["InjectionHandle", None], Protocol):
"""A prepared injection, ready to activate as an async context manager.

Returned by Surface.inject(). Entering activates the injection
Expand Down Expand Up @@ -58,8 +59,9 @@ async def __aenter__(self) -> Self:
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: types.TracebackType | None,
exc_value: BaseException | None,
traceback: types.TracebackType | None,
/,
) -> None:
"""Remove the injection. Must be idempotent. Must not raise."""
...
Expand Down
6 changes: 3 additions & 3 deletions rampart/drivers/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ def coerce_driver(
Returns:
PromptDriver: A driver wrapping the input.
"""
if isinstance(value, PromptDriver):
return value
if isinstance(value, str):
return StaticDriver(prompts=[value])
if isinstance(value, Request):
return StaticDriver(prompts=[value])
if isinstance(value, list):
return StaticDriver(prompts=value)
return value
return StaticDriver(prompts=value)
9 changes: 5 additions & 4 deletions rampart/evaluators/response_contains.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,17 @@ async def evaluate_async(self, *, context: EvalContext) -> EvalResult:
"""Check response text for the target pattern."""
text = context.text

if callable(self._target):
found = self._target(text)
elif isinstance(self._target, re.Pattern):
found = False
if isinstance(self._target, re.Pattern):
found = bool(self._target.search(text))
else:
elif isinstance(self._target, str):
check_text = text if self._case_sensitive else text.lower()
check_target = (
self._target if self._case_sensitive else self._target.lower()
)
found = check_target in check_text
elif callable(self._target):
found = self._target(text)

if found:
return EvalResult(
Expand Down
2 changes: 1 addition & 1 deletion rampart/pytest_plugin/_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ def add_sinks(self, *, sinks: list[ReportSink]) -> None:
TypeError: If any item does not satisfy ReportSink.
"""
for sink in sinks:
if not isinstance(sink, ReportSink): # pyright: ignore[reportUnnecessaryIsInstance]
if not isinstance(sink, ReportSink):
msg = (
f"Expected ReportSink, got {type(sink).__name__}. "
"Sinks must implement: "
Expand Down
21 changes: 14 additions & 7 deletions rampart/pytest_plugin/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import pytest

from rampart.core.execution import (
ExecutionEventHandler,
clear_default_handler_factory,
register_default_handler_factory,
)
Expand Down Expand Up @@ -128,6 +129,11 @@ def _resolve_trial_n(marker: pytest.Mark) -> int:
return raw


def _default_handler_factory() -> list[ExecutionEventHandler]:
"""Return the default execution handlers for every BaseExecution."""
return [ResultCollectionHandler()]


def pytest_configure(config: pytest.Config) -> None:
"""Register RAMPART markers and install default handler factory.

Expand All @@ -141,7 +147,7 @@ def pytest_configure(config: pytest.Config) -> None:
config.addinivalue_line("markers", "harm(*categories): categorize by harm type")
config.addinivalue_line("markers", "trial(n=, threshold=): statistical repetition")

register_default_handler_factory(lambda: [ResultCollectionHandler()])
register_default_handler_factory(_default_handler_factory)

config.stash[_rampart_key] = RampartSession()
config.stash[_session_start_key] = time.monotonic()
Expand Down Expand Up @@ -224,9 +230,10 @@ def _create_trial_clones(
if fixtureinfo is not None:
from_parent_kwargs["fixtureinfo"] = fixtureinfo

clone = type(item).from_parent(parent=parent, **from_parent_kwargs) # pyright: ignore[reportUnknownMemberType]
clone._rampart_trial_index = i # pyright: ignore[reportAttributeAccessIssue] # noqa: SLF001
clone._rampart_trial_base = item.nodeid # pyright: ignore[reportAttributeAccessIssue] # noqa: SLF001
clone = type(item).from_parent(parent=parent, **from_parent_kwargs)
# pytest.Item supports arbitrary user attributes for cross-hook state.
clone._rampart_trial_index = i # ty: ignore[unresolved-attribute] # noqa: SLF001
clone._rampart_trial_base = item.nodeid # ty: ignore[unresolved-attribute] # noqa: SLF001

_copy_markers_to_clone(source=item, clone=clone)
clone.add_marker(
Expand Down Expand Up @@ -308,7 +315,7 @@ def _absorb_results(


@pytest.fixture(autouse=True)
def _rampart_collect( # pyright: ignore[reportUnusedFunction] # pytest discovers this via autouse=True
def _rampart_collect( # pytest discovers this via autouse=True
request: pytest.FixtureRequest,
) -> Generator[None, None, None]:
"""Installed automatically on every test. Invisible to test authors.
Expand All @@ -326,7 +333,7 @@ def _rampart_collect( # pyright: ignore[reportUnusedFunction] # pytest discove
No test author ever imports or references this fixture.
"""
collector = ResultCollector()
node = cast("pytest.Item", request.node) # pyright: ignore[reportUnknownMemberType]
node = cast("pytest.Item", request.node)
rampart_session = request.config.stash.get(_rampart_key, None)
token = activate_collector(collector)
yield
Expand All @@ -348,7 +355,7 @@ def _rampart_collect( # pyright: ignore[reportUnusedFunction] # pytest discove


@pytest.fixture(scope="session", autouse=True)
def _rampart_sink_bootstrap( # pyright: ignore[reportUnusedFunction] # pytest discovers this via autouse=True
def _rampart_sink_bootstrap( # pytest discovers this via autouse=True
request: pytest.FixtureRequest,
) -> None:
"""Merge team-provided sinks into the RAMPART session.
Expand Down
6 changes: 4 additions & 2 deletions tests/unit/attacks/test_xpia.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from rampart.attacks import Attacks
from rampart.core.errors import InfrastructureError
from rampart.core.evaluator import Evaluator
from rampart.core.injection import InjectionHandle
from rampart.core.manifest import AppManifest
from rampart.core.result import SafetyStatus
from rampart.core.types import (
Expand All @@ -29,7 +31,7 @@ def _mock_handle(
payload_id: str | None = "p-001",
) -> AsyncMock:
"""Create an AsyncMock satisfying the InjectionHandle protocol."""
h = AsyncMock()
h = AsyncMock(spec=InjectionHandle)
h.surface_name = surface_name
h.payload_id = payload_id
h.__aenter__.return_value = h
Expand All @@ -44,7 +46,7 @@ def _mock_evaluator(
rationale: str = "",
) -> AsyncMock:
"""Create an AsyncMock evaluator returning a fixed EvalResult."""
evaluator = AsyncMock()
evaluator = AsyncMock(spec=Evaluator)
evaluator.evaluate_async.return_value = EvalResult(
outcome=outcome,
confidence=confidence,
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/core/test_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ class NotSession:
class TestAgentAdapterProtocolCheck:
def test_conforming_class_satisfies_protocol(self) -> None:
class MyAdapter:
async def create_session_async(self) -> Session: ...
async def create_session_async(self) -> Session:
raise NotImplementedError

@property
def manifest(self) -> AppManifest:
Expand Down
Loading
Loading