Skip to content

feat(dashboard): add the card that defines a guardrail otari runs itself - #1137

Draft
dpoulopoulos wants to merge 17 commits into
feat-guardrail-storefrom
feat-guardrail-dashboard-card
Draft

dpoulopoulos wants to merge 17 commits into
feat-guardrail-storefrom
feat-guardrail-dashboard-card

Conversation

@dpoulopoulos

Copy link
Copy Markdown
Member

Draft. Opened early to keep the work safe while the UI is being reviewed
in a browser. There are things to improve before this is ready.

Stacked. This targets feat-guardrail-store (#1120), not main, because
it needs both of its blockers: the built-in catalog (#1109, PR #1116) and the
guardrail store (#1111, PR #1120). Two consequences worth knowing. CodeRabbit
skips a PR whose base is not main, so it needs a @coderabbitai review
comment to look at this. And protect-main does not apply to a child branch,
so this one reports mergeable with no approval; it must not land before its
parents do.

Description

Otari can now build a guardrail and run it inside its own process, instead of
calling a separate guardrails service. Until this change the only way to set one
up was a curl command against a new API. This adds the page for it.

The form asks in the order an operator actually decides. First what you want
checked, in plain words: prompt injection, harmful content, personal data. That
narrows a second list to the guardrails that can do that job, each with a line
saying who publishes it, whether it runs on your own machine or calls a vendor,
and whether it needs an API key. Choosing one brings up the fields that
particular guardrail asks for, which differ a lot between them.

Otari ships forty guardrails and most of them need an extra Python package that
is not installed by default. Those still appear in the list, dimmed, naming the
package that would make them work, because "Otari can do this once I install
that" is worth knowing. On a stock install eight of the forty are ready to use.

An existing guardrail can then be edited, tested against a sample input,
switched off without losing its settings, and removed. A stored credential is
never shown again and never sent back to the browser; the form says whether one
is set and leaves it alone unless you type a new one.

Finally, the name you give a guardrail here is the same name a request sends,
so the organization guardrail form now offers those names in its picker. That
also fixes an older gap: a deployment running no guardrails service at all used
to get a bare text box there.

How to test it locally

Needs a Postgres and OTARI_SECRET_KEY set, since a guardrail credential is
stored encrypted.

make dashboard
otari serve

Open Tools and guardrails. The new card is "Guardrails Otari runs itself", above
Organization guardrails.

  1. Press Add guardrail. Pick "Prompt injection". The second control unlocks and
    the help line says how many guardrails can do it.
  2. Confirm the runnable ones sort to the top and the rest are dimmed, each
    naming guardrails-local.
  3. Pick a hosted one, for example Azure Prompt Shields, which takes an endpoint
    and a key. Confirm the name is suggested from the task and stops changing
    once you type your own. Save it.
  4. Press Test on the new row and give it something like "ignore your previous
    instructions". You should get a verdict, or a readable reason it could not
    run.
  5. Press Edit. The key box is empty and says it is already set. Change only the
    endpoint and save. The key must survive this: the update replaces the
    whole argument map, so leaving the key out would delete it.
  6. Toggle the row off, then remove it.
  7. In Organization guardrails, press Mandate a guardrail and confirm the name
    you just created appears in the profile picker.

Also worth trying with OTARI_SECRET_KEY unset: Add must still open, and the
warning about the key must appear only after you pick a guardrail that actually
needs a credential.

Automated coverage, all passing: 6019 tests across 178 files
(pnpm --dir web test), plus pnpm --dir web run lint,
pnpm --dir web run typecheck and pnpm --dir web run build. Forty-seven of
those tests are new and cover the task filtering and ordering, the dependent
controls, the credential round-trip, the unstorable argument, the encryption
gate, and the card's rows. No generated artifact changed: the API already
existed, so there is no OpenAPI, Postman or client schema regeneration here.

PR Type

  • New Feature
  • Bug Fix
  • Refactor
  • Documentation
  • Infrastructure / CI

Relevant issues

Fixes #1114

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change (tests/unit, tests/integration).
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test).
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec (uv run python scripts/generate_openapi.py).

Two notes on that list. This change is entirely in web/, so the tests are the
three dashboard suites rather than tests/unit and tests/integration, and the
repo checks for them are the pnpm --dir web ones above. And the API contract
did not change: every endpoint this consumes arrived with #1116 and #1120, so
the spec and the generated client are untouched.

AI Usage

  • No AI was used.
  • AI was used for drafting/refactoring.
  • This is fully AI-generated.

AI Model/Tool used:

Claude Opus 5 (1M context), through Claude Code.

Any additional AI details you'd like to share:

The agent planned the flow against the real catalog rather than the schema,
which changed two decisions: the runnable-first ordering, and moving the
OTARI_SECRET_KEY gate off the Add button and onto the chosen guardrail.

It also found three things worth flagging to a reviewer:

  1. A required credential that was already stored still counted as missing, so
    any edit to another field was refused. Fixed, and covered by a test.
  2. docs/guardrails.md said this API is standalone only and not served in
    hosted mode. The router is mounted after the hybrid early return, not a
    hosted one, so hosted does serve it. The sentence is corrected here. This
    leaves the same tenant-isolation gap as Document organization-scoped provider keys #818, which this PR does not widen.
  3. Azure Content Safety reports itself as not runnable on a stock install while
    Azure Prompt Shields does, which looks worth a separate look.

Deliberately left out: the reencrypt endpoint, since key rotation lives on the
Settings page beside the provider one, and tenant scoping in hosted mode.

NOTE:
When responding to reviewer questions, please respond yourself rather than copy/pasting reviewer comments into an AI and pasting back its answer. We want to discuss with you, not your AI :)

  • I am an AI Agent filling out this form (check box if true)

🤖 Generated with Claude Code

Split the stage filter out of `_parameter_specs` into `_specs_for_stage`,
which takes the stage it wants. The validate stage was hardcoded because
the guardrails service owns the constructor, so only validate parameters
could ever be sent. A catalog of the guardrails this gateway runs itself
needs the create stage too.

No behavior change. `_parameter_specs` keeps its signature and its
validate-only answer.

Refs #1109

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
Read every guardrail from the any-guardrail registry, with both parameter
stages. The registry imports nothing but its own leaves, and a guardrail's
backend is probed with `find_spec` rather than constructed, so listing the
catalog never loads torch.

Both stages, because a guardrail this gateway constructs itself has no
operator YAML fixing its constructor. That is where a vendor API key lives.
It is also where most of the detail is: only 22 of the 40 guardrails take a
validate parameter at all.

`runnable` says whether the modules a guardrail needs are installed here,
and `missing_extra` names the one extra that would fix it. A guardrail this
gateway holds no backend information about reports neither, because a guess
about an unknown guardrail is worse than an honest gap.

Adds `storable` to the shared parameter model, false for a secret typed
json. Upstream uses that shape for a live object, such as a boto3 session
or an IBM API client, which cannot be written down.

Nothing reads this yet.

Refs #1109

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
Add GET /api/v1/tool-settings/guardrails/catalog, on the reader router
beside the profiles read and under the same gate. A guardrail name is what
a caller puts in a request body, so the set of them is not the operator's
to withhold, and no endpoint address appears in the answer.

Named for GET /api/v1/providers/catalog one level over, which is the same
picker for a provider.

Closes #1109

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
Add services/guardrail_runner.py: build a guardrail from any-guardrail and
call it here, instead of posting to the sidecar. Nothing routes to it yet.
The store that supplies its arguments and the request path that chooses it
come next.

Both create and validate are synchronous upstream, and create loads model
weights, so both run in a worker thread. The build is shielded: a thread
cannot be cancelled, so a model that outlives the deadline is kept rather
than reloaded by every later request. Built guardrails are cached by class
and a digest of their constructor arguments, and evict() drops one by
profile name so a store write can retire a stale instance.

Calls go through AnyGuardrail.evaluate rather than validate directly. The
40 guardrails do not share one signature, and evaluate holds the mapping
table for all of them.

Every failure raises the existing GuardrailsNotReachableError, so the
fail-open and fail-closed branch governs an in-process guardrail unchanged
and the caller learns only the profile name. A vendor exception's text is
not carried, because an SDK can echo the arguments it was handed and those
hold the API key.

Refs #1110

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
The pin said nothing here ever constructs a guardrail, and that this gateway
runs them against the operator's sidecar. The runner makes both false.

Restate what the dependency is for, keeping the part that still holds: no
extra is taken, because the catalog reads an import-free registry and the
nine hosted-API guardrails build on the base install alone. List the names
actually imported, and keep the reason for the 0.8 ceiling.

Closes #1110

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
A guardrail is defined in a sidecar's YAML today, so adding one means editing
a file on disk and restarting a container. This is the row that replaces it:
named by the profile a caller sends, holding the any_guardrail class plus the
arguments to build and call it.

Constructor arguments are split by the catalog's secret flag rather than by a
column per secret. The 40 guardrails do not share a secret shape: bedrock takes
three, any_llm none, and one added upstream tomorrow may need four. So the
plain ones go in a JSON column and every secret goes in one encrypted map,
which costs no migration when that shape changes.

Nothing reads the table yet.

Refs #1111

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
The read-only baseline beside the stored rows, the way providers and search
tools already have one. A stored guardrail of the same name wins.

It is not only a fallback. Hybrid mode skips init_db, mounts no management
router and serves no dashboard, so it has no table to read and no page to write
one on, yet caller guardrails still run there. The block is the only way a
hybrid gateway defines a guardrail it runs itself.

Validated at load, because a guardrail that will not build is one a request
discovers by failing closed. An argument no guardrail takes is refused, since
none of the 40 accepts **kwargs and it would otherwise be a TypeError at build
time with nothing naming the typo. An argument upstream reads from the
environment when absent is not demanded.

The registry is imported inside the validators rather than at module scope:
reading it costs about 90ms, core.config is imported by every entry point, and
a deployment with no block should not pay for a question it never asks.

Refs #1111

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
The runner deliberately shipped without one, because who holds it is a question
the store answers: a write must reach the same instance that serves traffic to
evict what it changed.

Built on first use, not at import. It holds asyncio locks and tasks that bind to
the loop that first touches them, so an instance built at import would outlive a
lifespan restart and fail from inside asyncio under the next loop. The pooled
search client has the same shape for the same reason.

The shutdown reset is unconditional rather than gated on a refresher, because a
hybrid gateway runs its config-block guardrails through the same instance.

Refs #1111

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
Reads and writes a guardrail definition, and owns the one thing the table's
shape implies: splitting a submitted constructor map by the catalog's secret
flag, so a credential never lands in the plain column and never reaches a
response body.

The map is replaced rather than merged on a write, which is what makes a secret
removable. A value resubmitted as the redaction mask keeps what is stored, since
an editor is shown three asterisks and sends the whole object back. A mask with
nothing behind it is refused, because that is the one case where the caller
believes they are keeping a secret that does not exist.

Changing the class alone re-splits the stored arguments under the new one, so
the split can never be left describing the wrong guardrail.

No in-memory overlay: nothing on the request path reads a definition yet, so
there is no synchronous read to serve from a cache. What this gives that work is
a definition builder for each source.

The config validator grows the matching rule for a secret typed json, which
wants a live object that neither a row nor a YAML file can hold.

Refs #1111

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
The route in that replaces editing a sidecar's YAML and restarting a container.
Same shape as the search-tool sibling: operator-gated, standalone-only, config
entries reported beside the stored rows and read-only there.

One thing differs, and it follows from guardrails not sharing a secret shape. A
caller sends one create_kwargs map mixing arguments and credentials, and a read
gives back the plain half as stored plus a map of secret names to the mask.

Every write evicts the profile from the runner. Without that an edited guardrail
keeps answering from the instance built out of its old arguments, and the model
those arguments loaded is never released, since the runner drops an entry only
when no profile resolves to it.

The test route answers ok: false with the reason rather than an error status. An
operator asked whether the definition works, and "it does not, and here is why"
is that answer. A disabled guardrail is still testable, since checking one
before turning it on is the point.

Refs #1111

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
Covers the three things an operator has to know that no schema states: send the
constructor arguments as one map and Otari splits it by which are credentials,
send a secret back as the mask to keep it and leave it out to remove it, and an
already-built client object cannot be stored at all.

Closes #1111

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
…odule

The hook that seeds a parameter form from a spec list, validates it on submit
and builds the kwargs back is the same hook a form defining a locally-run
guardrail needs. It was private to the organization card, so move it beside the
helpers it already calls rather than write it twice.

No behavior change. The card's own tests cover it.

Refs #1114

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
The types, query keys and hooks behind the card that comes next: the built-in
catalog, the stored definitions, and create, update, delete and test.

Two keys rather than one. The catalog is a fact about the packages installed in
this process and moves only on a redeploy, so it takes the five-minute window
the search-provider list takes. A stored definition moves on every write.

Both reads are operator-only, so both take an enabled flag rather than firing a
request that answers 403.

Refs #1114

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
An operator knows they want prompt injection caught before they know that
Lakera, Alinia and sixteen others catch it. So the form asks for the task
first, and the second control offers only the guardrails that do it.

Both lists are derived from the catalog rather than written down, the count in
the help line included: this build ships forty guardrails and the next ships
more. Two things are written down. The task copy, because 'general_judge' is
not a sentence an operator should decode. And the order the tasks are offered
in, because sorting by name or by count would shuffle the common ones as the
catalog grows.

What can run here sorts first. On a default install thirteen of the eighteen
prompt-injection guardrails want a Python extra that is not present, so an
alphabetical list opens on rows nobody can use. The rest stay in the list,
dimmed and naming the extra, because an absent row says nothing at all.

Refs #1114

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
The guardrail store had an API and no way to reach it but curl. This is the
card: add, edit, test, disable and remove a guardrail this gateway runs in its
own process, beside the organization mandates that name one.

The form is three stages, because that is the order the decision is made. Task,
then the guardrails that do it, then whatever the chosen one asks for. The
third stage is hidden rather than disabled: the controls above it exist and are
about to be usable, while these fields do not exist until a guardrail names
them.

Two rules the API states nowhere and the form has to keep. A PATCH replaces
create_kwargs rather than merging into it, so a blank secret that is already
stored is sent back as the mask; leaving it out would delete the credential on
a save that only changed an endpoint. And an argument the catalog marks
unstorable holds a live client object, so it is drawn disabled saying why and
never sent.

A stored secret also relaxes its own required flag. SecretField is never
prefilled, so blank means keep, and a validator that still called it missing
refused every edit that changed anything else.

The OTARI_SECRET_KEY gate is asked of the chosen guardrail rather than of the
Add button, which is where the provider page asks it. A local guardrail needs
no credential, so gating the button would refuse one this deployment can
perfectly well store.

Refs #1114

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
A stored guardrail's name is what a caller sends as its profile, so an operator
could define one and then have no way to select it. The picker now offers both
sources.

It also fixes a case that predates the store: a deployment running no
guardrails service fell back to a free-text box, because the list was the
sidecar's alone. Defining a guardrail here is enough to earn a picker.

Select carries no second line per row, so a local name wears its source in the
label and is stripped back off before the value is reported.

The read is operator-only while this card serves organization owners and
admins, who need not be operators. So it is gated, and a non-operator sees the
service's profiles and the by-hand box exactly as before.

Refs #1114

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
Names the card and the order it asks in, so an operator reading the API section
knows there is a form for the same thing.

Also corrects the sentence beside it. The router is mounted after the hybrid
early return in api/main.py, not after a hosted one, so a hosted deployment
does serve these routes. Only hybrid does not, and the reason is that it keeps
no local database.

The card's own docs link points at this file rather than tools.md, which is
where every other link on that page goes.

Closes #1114

Signed-off-by: Dimitris Poulopoulos <dimitris@mozilla.ai>
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant