Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b20ea9a
refactor(guardrails): read parameter specs by stage
dpoulopoulos Sep 13, 2026
ebf7b9b
feat(guardrails): build a catalog of the guardrails otari ships
dpoulopoulos Sep 13, 2026
d9755d2
feat(api): serve the built-in guardrail catalog
dpoulopoulos Sep 13, 2026
63a86f1
feat(guardrails): run a guardrail in this process
dpoulopoulos Sep 14, 2026
9cfffe6
docs(deps): correct the any-guardrail pin comment
dpoulopoulos Sep 14, 2026
40fe6db
feat(guardrails): add the guardrail_credentials table
dpoulopoulos Sep 14, 2026
54b8a95
feat(config): accept a guardrails block in config.yml
dpoulopoulos Sep 14, 2026
13756a3
feat(guardrails): hold one guardrail runner for the process
dpoulopoulos Sep 14, 2026
edac27a
feat(guardrails): add the guardrail store service
dpoulopoulos Sep 14, 2026
cf9379a
feat(api): manage stored guardrail definitions
dpoulopoulos Sep 14, 2026
0f014ce
docs(guardrails): document stored guardrail definitions
dpoulopoulos Sep 14, 2026
036a27a
refactor(dashboard): lift the guardrail parameter form into its own m…
dpoulopoulos Sep 14, 2026
2cdb8a0
feat(dashboard): read and write locally defined guardrails
dpoulopoulos Sep 14, 2026
bac8e0b
feat(dashboard): pick a guardrail by the task it does
dpoulopoulos Sep 14, 2026
01b33da
feat(dashboard): add the card that defines a guardrail otari runs itself
dpoulopoulos Sep 14, 2026
dd46b7a
feat(dashboard): list locally defined guardrails in the profile picker
dpoulopoulos Sep 14, 2026
13c37d4
docs(guardrails): say the dashboard can define a guardrail
dpoulopoulos Sep 14, 2026
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
41 changes: 41 additions & 0 deletions alembic/versions/a7e3c9d1f5b2_add_guardrail_credentials.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Add guardrail_credentials table.

Revision ID: a7e3c9d1f5b2
Revises: f1c4a8e2d6b9
Create Date: 2026-09-14 09:00:00.000000

"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "a7e3c9d1f5b2"
down_revision: str | Sequence[str] | None = "f1c4a8e2d6b9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
op.create_table(
"guardrail_credentials",
sa.Column("name", sa.String(), nullable=False),
sa.Column("guardrail_name", sa.String(), nullable=False),
sa.Column("create_kwargs", sa.JSON(), nullable=False),
sa.Column("encrypted_create_secrets", sa.Text(), nullable=True),
sa.Column("validate_kwargs", sa.JSON(), nullable=False),
# Server default so the column is non-null for any row written by code
# that predates it; there are none today, but the rule is the repo's.
sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("name"),
)


def downgrade() -> None:
"""Downgrade schema."""
op.drop_table("guardrail_credentials")
10 changes: 10 additions & 0 deletions config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,13 @@ providers:
# local:
# provider: searxng
# api_base: "http://searxng:8080"

# Guardrails this gateway builds and runs itself. The key is the name a request
# sends as a guardrail entry's "profile". A guardrail stored through the
# dashboard wins over an entry here of the same name. Keep secrets in the
# environment: values here are read as written. See docs/guardrails.md.
# guardrails:
# prompt-injection:
# guardrail_name: lakera_guard
# create_kwargs:
# api_key: "${LAKERA_API_KEY}"
37 changes: 37 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,43 @@ each requires an `api_key` or `api_base`. Provider options and request filters
are covered in [Built-in tools](tools.md). A tool carrying an `api_key` must use
an HTTPS `api_base`; a keyless local SearXNG endpoint may use HTTP.

## Guardrails

`guardrails` defines the guardrails this gateway builds and runs itself. Each key
is the name a request sends as a guardrail entry's `profile`.

```yaml
guardrails:
prompt-injection:
guardrail_name: lakera_guard
create_kwargs:
api_key: "${LAKERA_API_KEY}"
validate_kwargs: {}
enabled: true
```

`guardrail_name` is the any-guardrail class;
`GET /api/v1/tool-settings/guardrails/catalog` lists every one this build ships,
with the arguments each accepts. `create_kwargs` are constructor arguments,
`validate_kwargs` are sent on every check, and `enabled` defaults to true.

The same guardrails can be managed at runtime from `/api/v1/guardrail-credentials`,
which is what the dashboard writes. That API is standalone-only: hosted and hybrid
deployments do not serve it, and a hybrid gateway has no database to store a
guardrail in, so this block is its only way to define one. A stored guardrail wins over a config-file one
of the same name, so the file is a baseline rather than an override. Config
entries stay read-only through the API.

Secrets in a stored guardrail are encrypted with `OTARI_SECRET_KEY` and never
returned; a read shows which ones are set, masked. Secrets in this file are not,
so use `${VAR}` interpolation rather than writing a key into a file you commit.

Entries are validated at load: an unknown class, an argument no guardrail takes,
or a missing required one refuses startup rather than failing the first request.

This block is the only way to define an in-process guardrail in hybrid mode,
which keeps no local database. See [Guardrails](guardrails.md).

## Mail

Mail is optional. Invitations still return an accept link when no transport is
Expand Down
73 changes: 73 additions & 0 deletions docs/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,79 @@ the dashboard falls back to naming a profile by hand. The same fallback covers
an entry that points at an endpoint of its own: only `guardrails_url` is read
here, because a URL taken from an entry would be one a caller chose.

### Defining a guardrail Otari runs itself

Otari can also build a guardrail and run it in its own process, with no second
container. A guardrail defined this way is a row Otari owns rather than an entry
in a file the guardrails service reads, so adding one takes no restart.

The dashboard does all of this under Tools and guardrails, in "Guardrails Otari
runs itself". Pick what you want checked, such as prompt injection, and the
second list narrows to the guardrails that check it, with whatever each one
needs below. A guardrail whose packages are not installed is shown anyway,
dimmed, naming the extra that would make it runnable. The rest of this section
is the same thing through the API.

`GET /api/v1/tool-settings/guardrails/catalog` lists every guardrail this build
ships, with both stages of arguments: `create` for the constructor, where a
vendor API key lives, and `validate` for the per-call ones. A guardrail whose
packages are not installed reports `runnable: false` and names the extra that
would fix it.

`POST /api/v1/guardrail-credentials` defines one. It is operator-only, and a
hybrid gateway does not serve it at all, because a hybrid deployment keeps no
local database. The name is what a request sends as its `profile`:

```bash
curl -X POST http://localhost:8000/api/v1/guardrail-credentials \
-H "Otari-Key: Bearer $OTARI_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "prompt-injection",
"guardrail_name": "lakera_guard",
"create_kwargs": {"api_key": "lakera-live-..."}
}'
```

Send the constructor arguments as one `create_kwargs` map. Otari splits it:
whatever the catalog marks as a credential is encrypted with `OTARI_SECRET_KEY`
and never returned, and the rest is stored as written. A read gives back the
plain arguments plus a `create_secrets` map showing which secrets are set:

```json
{
"name": "prompt-injection",
"guardrail_name": "lakera_guard",
"create_kwargs": {},
"create_secrets": {"api_key": "***"},
"enabled": true,
"decryptable": true
}
```

To edit one, `PATCH /api/v1/guardrail-credentials/{name}` with the whole
`create_kwargs` map. Send a secret back as `"***"` to keep it, a new value to
rotate it, or leave it out to remove it. `POST /{name}/test` runs the guardrail
once against a sample input, so a definition can be checked before anything
relies on it, including one that is not enabled yet.

A few arguments cannot be stored. Upstream lets a caller pass an already-built
client object for `boto3_session` or `api_client`, and no database can hold a
live connection, so those are refused. Use the credential arguments beside them
instead: `aws_access_key_id` and `aws_secret_access_key` for Bedrock, `api_key`
and `url` for watsonx.

The same guardrails can be written into `config.yml` as a read-only baseline; see
[Configuration](configuration.md). A stored guardrail wins over a file entry of
the same name. In hybrid mode the file is the only source, because a hybrid
gateway keeps no local database.

Rotating `OTARI_SECRET_KEY` works the same way it does for providers: set it to
`new,old`, restart, call `POST /api/v1/guardrail-credentials/reencrypt` alongside
the provider and search-tool endpoints, then drop the old key and restart again.
A guardrail whose secrets no longer decrypt is reported with
`"decryptable": false` rather than looking like one with no secrets at all.

### How the layers compose

Three layers can name a guardrail: the caller's request, the caller's
Expand Down
Loading