mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
* feat(sse): unified media-part detection helper (image+audio, input_image) * refactor(guardrails): extractImageParts/comboStructure delegate to unified media detector * fix(sse): media detector — audio parts no longer shadow sibling/nested image indicators * fix(guardrails): close extract↔replace contract for input_image (allowlist + splice) * perf(guardrails): skip media traversal when bridge disabled; short-circuit combo image check * feat(guardrails): in-memory LRU bridge cache (sha256 keyed) * feat(settings): modalityBridge* schema with legacy visionBridge* fallback * feat(db): migrate visionBridge* settings to modalityBridge* (idempotent) * refactor(guardrails): harden bridge cache key/config + settings resolution (review minors) * feat(guardrails): vision bridge mode selector (auto/describe/reroute) short-circuit * feat(guardrails): task-aware vision description prompt (default on) * feat(guardrails): describe-path cache integration * docs(guardrails): review polish — cache-key coupling notes + helper header * feat(guardrails): in-memory bridge stats + modality-bridge response header * feat(api): modality bridge stats endpoint + header wiring in chat handler * docs(guardrails): document modality bridge mode/task-aware/cache/header + stats endpoint * chore: untrack _tasks symlink (inherited from base tip; blocks pre-commit tracked-artifacts gate) * fix(db): renumber modality bridge migration 139->140 (base renumbered ccr_blocks to 139) * docs(guardrails): migration filename touch-up 139->140 * docs(db): stale comment touch-ups after 139->140 renumber and #9688 landing * fix(db): renumber modality bridge migration 140->141 (base renumbered connection_runtime_state to 140) * test(db): migration test titles 139->141 --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
448 lines
25 KiB
Markdown
448 lines
25 KiB
Markdown
---
|
||
title: "Guardrails"
|
||
version: 3.8.50
|
||
lastUpdated: 2026-08-07
|
||
---
|
||
|
||
# Guardrails
|
||
|
||
> **Source of truth:** `src/lib/guardrails/`
|
||
> **Last updated:** 2026-08-07 — v3.8.50 (Modality Bridge PR-1: mode selector, task-aware prompt, describe cache, transparency header + stats)
|
||
|
||
Guardrails enforce safety, policy, and content transformations at the boundary
|
||
between OmniRoute and upstream providers. Each guardrail can inspect (and
|
||
optionally reject, transform, or annotate) request payloads (`preCall`) and
|
||
upstream responses (`postCall`).
|
||
|
||
The system is **fail-open**: if a guardrail throws while executing, the registry
|
||
records the error and continues with the next guardrail rather than failing the
|
||
request. Blocking is an explicit decision (`block: true`), never an accident.
|
||
|
||
## Built-in Guardrails
|
||
|
||
The registry auto-loads four guardrails in priority order on import
|
||
(see `registry.ts` → `registerDefaultGuardrails()`):
|
||
|
||
| Priority | Name | Stage(s) | File |
|
||
| -------- | ------------------- | -------------- | --------------------- |
|
||
| `5` | `vision-bridge` | `preCall` | `visionBridge.ts` |
|
||
| `10` | `pii-masker` | `pre` + `post` | `piiMasker.ts` |
|
||
| `20` | `prompt-injection` | `preCall` | `promptInjection.ts` |
|
||
| `95` | `credential-masker` | `pre` + `post` | `credentialMasker.ts` |
|
||
|
||
Lower priority numbers run **first**.
|
||
|
||
### Vision Bridge (`visionBridge.ts`) — Modality Bridge PR-1
|
||
|
||
Intercepts image-bearing requests aimed at **non-vision models** and either
|
||
reroutes the whole request to a vision-capable model or replaces the image
|
||
parts with text descriptions produced by a configurable vision model before
|
||
the upstream call. This lets text-only providers transparently handle
|
||
multimodal payloads.
|
||
|
||
Flow:
|
||
|
||
1. Skip if the target model already supports vision (unless it appears in the
|
||
forced-bridge list `isVisionBridgeForcedModel`).
|
||
2. Extract image parts via `extractImageParts(messages)`
|
||
(`visionBridgeHelpers.ts`), which delegates to the **unified media
|
||
detector** `detectMediaParts()` in `open-sse/utils/mediaParts.ts` — the
|
||
single source of truth shared with the combo compatibility filter.
|
||
Extraction is allowlisted to top-level parts of the shapes
|
||
`replaceImageParts` can splice back (the extract↔replace contract): OpenAI
|
||
`image_url`, Anthropic base64 `source.type:"base64"`, Anthropic URL
|
||
`source.type:"url"`, and Responses API `input_image`. Nested hits and
|
||
indicator-only shapes are combo-filter material and are never extracted.
|
||
Skip if none found.
|
||
3. Resolve runtime config via `resolveVisionBridgeRuntimeSettings()`
|
||
(`src/shared/constants/modalityBridgeDefaults.ts`): new `modalityBridge*`
|
||
settings keys win; legacy `visionBridge*` keys remain a **one-cycle
|
||
fallback** (rollback window). Skip before any media traversal when the
|
||
bridge is disabled.
|
||
4. Mode selector (`modalityBridgeVisionMode`, see table below) decides
|
||
reroute vs describe. Reroute returns `modifiedPayload` with only `model`
|
||
swapped, plus meta `{ rerouted, fromModel, toModel, imagesKept }`.
|
||
5. Describe path: cap images at `maxImages`, compose the task-aware prompt,
|
||
consult the describe cache, call the vision model **in parallel**
|
||
(`Promise.allSettled`), and inject `[Image N]: <description>` text parts in
|
||
their place. A failed describe yields `null` and the original image part is
|
||
**preserved** (#4012) — except on the combo describe path when every
|
||
describe failed, where a confirmed non-vision upstream gets an
|
||
`(unavailable — no vision-capable provider connected)` stub instead (#8430).
|
||
6. Return `modifiedPayload` + meta (`imagesProcessed`, `descriptions`,
|
||
`processingTimeMs`, `visionModel`).
|
||
|
||
#### Mode selector (`modalityBridgeVisionMode`)
|
||
|
||
| Mode | Default | Behavior |
|
||
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `auto` | ✔ | Legacy heuristic, untouched (#6640/#7204): non-combo/`auto/` models reroute to the best vision model unless the original model already has usable credentials (then describe); combo targets always describe. |
|
||
| `describe` | | Always describe — the reroute block is skipped entirely; the user's chosen model always answers. |
|
||
| `reroute` | | Force reroute: the keep-credentialed-model guard is bypassed. The reroute-**target** credential guard still applies — when no usable vision target exists, the request falls through to describe so raw images never reach a text-only backend (#8430). |
|
||
|
||
Forced modes short-circuit **before** the auto heuristic runs; `auto` behavior
|
||
is byte-identical to the pre-PR-1 guardrail.
|
||
|
||
#### Task-aware describe prompt (`modalityBridgeVisionTaskAware`)
|
||
|
||
Default **true**. `composeVisionPrompt()` (`visionBridgeHelpers.ts`) appends
|
||
the text of the **last user message** (truncated to 500 chars) to the base
|
||
describe prompt, steering the description toward what the user actually asked
|
||
(codex-vision-proxy pattern) and asking the vision model to transcribe visible
|
||
text. With the flag off — or no user text — the base prompt is used unchanged.
|
||
|
||
#### Describe cache (`modalityBridge/bridgeCache.ts`)
|
||
|
||
In-memory LRU + TTL cache for describe outputs, shared process-wide.
|
||
Key = `sha256(imageRef + composedPrompt + configuredBridgeModel)` with
|
||
length-prefix framing (no field-boundary collisions). The model component is
|
||
the **configured** bridge model, not the model that actually answered —
|
||
`callVisionModel` may fall back internally, and keying per attempt would
|
||
fragment the cache. Failed describes are never cached. Settings:
|
||
|
||
| Key | Default | Range |
|
||
| ------------------------------- | ------- | ------- |
|
||
| `modalityBridgeCacheEnabled` | `true` | — |
|
||
| `modalityBridgeCacheTtlMinutes` | `60` | 1–1440 |
|
||
| `modalityBridgeCacheMaxEntries` | `200` | 10–5000 |
|
||
|
||
#### Settings schema + migration
|
||
|
||
The new `modalityBridge*` keys are Zod-validated in `updateSettingsSchema`
|
||
(`src/shared/validation/settingsSchemas.ts`): `modalityBridgeVisionEnabled`,
|
||
`modalityBridgeVisionMode`, `modalityBridgeVisionModel`,
|
||
`modalityBridgeVisionTaskAware`, `modalityBridgeVisionPrompt`,
|
||
`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`, the
|
||
`modalityBridgeCache*` trio, and the PR-3-reserved `modalityBridgeAudio*`
|
||
group. Migration `141_modality_bridge_settings.sql` copies existing legacy
|
||
`visionBridge*` values to the matching new keys (idempotent, never overwrites
|
||
an operator-set `modalityBridge*` value); the legacy keys stay accepted as a
|
||
read fallback for one release cycle.
|
||
|
||
#### Transparency header + stats
|
||
|
||
Describe-transformed responses carry
|
||
`x-omniroute-modality-bridge: image->text;model=<visionModel>;parts=<n>`
|
||
(built by `buildModalityBridgeHeader()` in `modalityBridge/bridgeStats.ts`,
|
||
stamped by `withModalityBridgeHeader()` in `src/sse/handlers/chatHelpers.ts`).
|
||
Rerouted requests get **no** header — the payload was untouched and the model
|
||
swap is already visible in the response body's `model` field.
|
||
|
||
`GET /api/modality-bridge/stats` (management auth, same tier as
|
||
`GET /api/settings`) returns the in-memory per-modality counters
|
||
`{ bridged, cacheHits, failures, lastUsedAt }` for `vision` (and the
|
||
PR-3-reserved `audio`). Counters reset on process restart by design
|
||
(telemetry, not accounting).
|
||
|
||
**Self-loop admission bypass:** when the describe call routes through OmniRoute's
|
||
own `/v1` self-loop (non-standard provider model), the sub-request sends
|
||
`x-omniroute-admission-bypass: internal` and is authenticated with the resolved
|
||
self-loop credential — the local `sk_omniroute` sentinel in local mode, or the
|
||
operator-configured `OMNIROUTE_API_KEY` / `ROUTER_API_KEY` env key (#1350) so
|
||
`REQUIRE_API_KEY=true` deployments can still run the describe call. The bypass
|
||
is only honored for those exact credentials, so external clients cannot use the
|
||
header to skip admission.
|
||
|
||
Legacy defaults live in `src/shared/constants/visionBridgeDefaults.ts`; the
|
||
new mode/task-aware/cache defaults and the settings resolver live in
|
||
`src/shared/constants/modalityBridgeDefaults.ts`. The guardrail exposes a
|
||
`deps` constructor option so tests can inject fake `getSettings` and
|
||
`callVisionModel` implementations.
|
||
|
||
### PII Masker (`piiMasker.ts`)
|
||
|
||
Runs on **both** stages.
|
||
|
||
- **`preCall`** clones the payload, walks `system`, `messages`, `input`, and
|
||
`prompt` (including plain string items), and applies `processPII()` (from
|
||
`@/shared/utils/inputSanitizer`) to string `content`/`text` fields. When
|
||
`PII_REDACTION_ENABLED=true`, detected PII is redacted in the outbound
|
||
payload. This is independent of `INPUT_SANITIZER_MODE` (which only controls
|
||
prompt-injection policy). When redaction is off, the call records detection
|
||
counts without rewriting content.
|
||
- **`postCall`** deep-clones the response, runs `sanitizePIIResponse()` plus
|
||
the Responses-API-shape masker (`maskResponsesOutput` — covers
|
||
`output_text` and `output[].content[].text`). If any redaction occurs, the
|
||
modified response replaces the original.
|
||
|
||
The guardrail never blocks; it only annotates (`meta.detections`,
|
||
`meta.redacted`) or rewrites.
|
||
|
||
### Prompt Injection (`promptInjection.ts`)
|
||
|
||
Detects adversarial structures in user-supplied content and enforces the
|
||
configured policy. Behavior is driven by environment variables and constructor
|
||
options:
|
||
|
||
| Setting | Env var | Default | Effect |
|
||
| --------------- | ----------------------------------------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| Enabled | `INPUT_SANITIZER_ENABLED` | `true` | When `false`, guardrail short-circuits. |
|
||
| Mode | `INJECTION_GUARD_MODE` / `INPUT_SANITIZER_MODE` | `warn` | Injection policy: `block`, `warn`, or `log`. (`redact` is accepted for back-compat but does **not** strip injection text; request PII rewrite is controlled by `PII_REDACTION_ENABLED`.) |
|
||
| Block threshold | `blockThreshold` option / `INPUT_SANITIZER_BLOCK_THRESHOLD` (alias `INJECTION_GUARD_BLOCK_THRESHOLD`) | `high` | Minimum severity required to block. Medium is observe-only at default. |
|
||
|
||
**Mode precedence** (`getMode`): caller `options.mode` →
|
||
`INJECTION_GUARD_MODE` **DB feature-flag override** (Dashboard → Settings →
|
||
Feature Flags) → `INJECTION_GUARD_MODE` env → `INPUT_SANITIZER_MODE` env →
|
||
`warn`. A dashboard override therefore wins over the env vars, so the Feature
|
||
Flags UI controls the running guard live (no restart). The DB read is fail-safe:
|
||
if it errors, the guard falls back to the env-based behavior, and when no
|
||
override is set behavior is identical to env-only resolution.
|
||
|
||
Detection sources:
|
||
|
||
1. `sanitizeRequest()` from `@/shared/utils/inputSanitizer` (shared detector
|
||
set used elsewhere in the pipeline).
|
||
2. Built-in `DEFAULT_GUARD_PATTERNS` (currently `system_override_inline` and
|
||
`markdown_system_block`, both `high` severity).
|
||
3. Optional `customPatterns` passed via constructor options (strings, regex,
|
||
or `{ name, pattern, severity }` records).
|
||
|
||
When `mode === "block"` **and** at least one detection meets the severity
|
||
threshold, `preCall` returns `{ block: true, message: "Request rejected:
|
||
suspicious content detected" }`. In `warn`/`log` modes the guardrail logs but
|
||
allows the call. The shared helper `evaluatePromptInjection()` is also exported
|
||
for callers that need to evaluate prompts without going through the registry.
|
||
|
||
**Scan bound (v3.8.20):** the detector only inspects the **first 16 KB** of
|
||
joined prompt text — `MAX_INJECTION_SCAN_BYTES = 16 * 1024` (16 384 bytes) in
|
||
`src/shared/utils/inputSanitizer.ts`. Both `detectInjection()` and
|
||
`evaluatePromptInjection()` `slice(0, MAX_INJECTION_SCAN_BYTES)` before running
|
||
the pattern loop. Injection directives sit near the top of an input, so this
|
||
caps regex CPU/GC on multi-hundred-KB payloads without weakening detection (cf.
|
||
#3932, #4041).
|
||
|
||
### Credential Masker (`credentialMasker.ts`)
|
||
|
||
Runs on **both** stages, last in the default chain (priority `95`). Redacts
|
||
well-known API-key / secret-token patterns from the outbound payload (message
|
||
content, tool-call arguments, tool results) **and** the provider response, so a
|
||
credential pasted into a prompt (or echoed back by a tool result) is not leaked
|
||
to the upstream provider or back to the client.
|
||
|
||
- **Opt-in only**, same convention as PII redaction (Hard Rule #20-adjacent):
|
||
disabled unless `settings.credentialRedactionEnabled === true` **or**
|
||
`CREDENTIAL_REDACTION_ENABLED=true`. With it off, the guardrail is a no-op —
|
||
it never blocks and never rewrites.
|
||
- `redactCredentials()` walks the full payload/response tree (`walkValue()`,
|
||
prototype-pollution-safe, cycle-safe via `WeakSet`) and replaces matches with
|
||
a `[REDACTED:<type>]` placeholder, cloning only the branches that actually
|
||
changed.
|
||
- `CREDENTIAL_PATTERNS` covers LLM provider keys (OpenAI, OpenAI-proj,
|
||
Anthropic, Google, Hugging Face, Replicate), VCS/SaaS tokens (GitHub, Slack,
|
||
Linear, Notion, npm, Postman, Discord), payment keys (Stripe, Square), cloud
|
||
keys (AWS access key, Twilio, SendGrid, Mailgun), private keys / JWTs,
|
||
credential-bearing connection strings (`mongodb://user:pass@...`, etc.), and
|
||
a generic `Authorization`/`x-api-key`/`api-key`/`apikey` header-value
|
||
pattern. Header-shaped keys (`authorization`, `x-api-key`, `api-key`,
|
||
`apikey`) are redacted structurally (value only, scheme prefix like
|
||
`Bearer `/`Basic ` preserved) rather than via the generic text regex.
|
||
- The guardrail never blocks; it only rewrites (`modifiedPayload` /
|
||
`modifiedResponse`) and annotates (`meta.credentialsRedacted`, `meta.count`).
|
||
|
||
Regression guard: `tests/unit/credential-masker-guardrail.test.ts`.
|
||
|
||
## Base Contract (`base.ts`)
|
||
|
||
```typescript
|
||
class BaseGuardrail {
|
||
enabled: boolean;
|
||
name: string;
|
||
priority: number;
|
||
|
||
constructor(name: string, options?: { enabled?: boolean; priority?: number });
|
||
|
||
async preCall(payload: unknown, context: GuardrailContext): Promise<GuardrailResult | void>;
|
||
|
||
async postCall(response: unknown, context: GuardrailContext): Promise<GuardrailResult | void>;
|
||
}
|
||
|
||
interface GuardrailResult<TValue = unknown> {
|
||
block?: boolean; // true short-circuits the chain
|
||
message?: string; // surfaced when blocking
|
||
meta?: Record<string, unknown> | null;
|
||
modifiedPayload?: TValue; // returned by preCall to rewrite the request
|
||
modifiedResponse?: TValue; // returned by postCall to rewrite the response
|
||
}
|
||
|
||
interface GuardrailContext {
|
||
apiKeyInfo?: Record<string, unknown> | null;
|
||
disabledGuardrails?: string[] | null;
|
||
endpoint?: string | null;
|
||
headers?: Headers | Record<string, unknown> | null;
|
||
log?: GuardrailLog | Console | null;
|
||
method?: string | null;
|
||
model?: string | null;
|
||
provider?: string | null;
|
||
sourceFormat?: string | null;
|
||
stream?: boolean;
|
||
targetFormat?: string | null;
|
||
}
|
||
```
|
||
|
||
A guardrail signals "no change" by returning either `void`, `{}`, or
|
||
`{ block: false }`. Returning a `modifiedPayload`/`modifiedResponse` replaces
|
||
the value flowing through the chain for downstream guardrails.
|
||
|
||
## Registry (`registry.ts`)
|
||
|
||
The singleton `guardrailRegistry` exposes:
|
||
|
||
- `register(guardrail)` — adds (or replaces by normalized name) a guardrail and
|
||
re-sorts by ascending `priority`.
|
||
- `clear()` / `list()` — administrative helpers.
|
||
- `runPreCallHooks(payload, context)` — iterates active guardrails, threads the
|
||
payload through `modifiedPayload`, and stops on the first `block: true`.
|
||
- `runPostCallHooks(response, context)` — same flow on the response side.
|
||
- `resetGuardrailsForTests({ registerDefaults })` — clears state and optionally
|
||
re-registers the defaults for clean test isolation.
|
||
|
||
Both runners return `{ blocked, payload|response, results, guardrail?, message? }`
|
||
where `results` is an array of `GuardrailExecutionResult` records that include
|
||
per-guardrail `blocked`, `skipped`, `modified`, `error`, and `meta` fields,
|
||
useful for tracing.
|
||
|
||
### Disabling Guardrails Per-Request
|
||
|
||
`resolveDisabledGuardrails({ apiKeyInfo, body, headers })` aggregates a
|
||
de-duplicated list of guardrail names that should be skipped for the current
|
||
request. Sources (all optional, all merged):
|
||
|
||
- `apiKeyInfo.disabledGuardrails`
|
||
- Request body `disabledGuardrails` (top-level)
|
||
- Request body `metadata.disabledGuardrails`
|
||
- Header `x-omniroute-disabled-guardrails` (or legacy
|
||
`x-disabled-guardrails`)
|
||
|
||
Values may be arrays of strings or a comma-separated string; names are
|
||
normalized to lowercase kebab-case (`pii_masker` → `pii-masker`). The result
|
||
is passed through `context.disabledGuardrails` to the registry, which skips
|
||
matching guardrails (`skipped: true` in `results`).
|
||
|
||
## Execution Order
|
||
|
||
For each request flowing through `src/sse/handlers/chat.ts` and
|
||
`open-sse/handlers/chatCore.ts`:
|
||
|
||
1. `resolveDisabledGuardrails(...)` builds the skip list from API key, body,
|
||
and headers.
|
||
2. `guardrailRegistry.runPreCallHooks(body, ctx)` runs guardrails in ascending
|
||
priority order:
|
||
- Disabled guardrails are recorded as `skipped`.
|
||
- Each guardrail's `preCall` may rewrite the payload via `modifiedPayload`.
|
||
- The first `block: true` short-circuits the chain and the handler returns
|
||
a guardrail rejection response.
|
||
3. The (potentially rewritten) payload flows into combo routing and upstream
|
||
dispatch.
|
||
4. After the response is assembled, `guardrailRegistry.runPostCallHooks(...)`
|
||
runs the same chain on the response. `block: true` here drops the upstream
|
||
response.
|
||
|
||
Guardrails that throw are recorded with `error: <message>` and logged via
|
||
`logger.warn`, but the chain continues — fail-open by design.
|
||
|
||
## Configuration
|
||
|
||
Environment variables read by the built-in guardrails:
|
||
|
||
| Variable | Used by | Effect |
|
||
| ------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------- |
|
||
| `INPUT_SANITIZER_ENABLED` | `prompt-injection` | Set `false` to disable detection entirely. |
|
||
| `INPUT_SANITIZER_MODE` | `prompt-injection` | Injection policy: `warn`, `block`, or `log`. Legacy value `redact` does not rewrite injection text. |
|
||
| `INJECTION_GUARD_MODE` | `prompt-injection` | Mode for the injection guard; also a DB feature flag that **overrides** the env vars (DB > ENV). |
|
||
| `INPUT_SANITIZER_BLOCK_THRESHOLD` | `prompt-injection` | Minimum severity that `MODE=block` rejects: `high` (default), `medium`, or `low`. |
|
||
| `INJECTION_GUARD_BLOCK_THRESHOLD` | `prompt-injection` | Legacy alias for `INPUT_SANITIZER_BLOCK_THRESHOLD`. |
|
||
| `PII_REDACTION_ENABLED` | `pii-masker` | When `true`, request PII is redacted (independent of injection mode). |
|
||
| `PII_RESPONSE_SANITIZATION` / `_MODE` | `pii-masker` (downstream) | Controls response-side masker behavior. |
|
||
|
||
The Vision Bridge reads runtime config from the DB-backed settings store
|
||
(`getSettings()`), not env vars: `visionBridgeEnabled`, `visionBridgeModel`,
|
||
`visionBridgePrompt`, `visionBridgeTimeout`, `visionBridgeMaxImages`. Defaults
|
||
live in `src/shared/constants/visionBridgeDefaults.ts`.
|
||
|
||
## Custom Guardrails
|
||
|
||
```typescript
|
||
import { BaseGuardrail, guardrailRegistry } from "@/lib/guardrails";
|
||
|
||
class BudgetGuardrail extends BaseGuardrail {
|
||
constructor() {
|
||
super("budget", { priority: 50 });
|
||
}
|
||
|
||
async preCall(payload, ctx) {
|
||
if (ctx.apiKeyInfo?.budgetExceeded) {
|
||
return { block: true, message: "Daily budget exceeded" };
|
||
}
|
||
return { block: false };
|
||
}
|
||
}
|
||
|
||
guardrailRegistry.register(new BudgetGuardrail());
|
||
```
|
||
|
||
Steps:
|
||
|
||
1. Create `src/lib/guardrails/myGuardrail.ts` extending `BaseGuardrail`.
|
||
2. Implement `preCall` and/or `postCall`.
|
||
3. Either register at import time (push from `registerDefaultGuardrails`) or
|
||
call `guardrailRegistry.register(...)` at runtime — the registry replaces
|
||
any prior guardrail with the same normalized name.
|
||
4. Add tests under `tests/unit/` (existing examples:
|
||
`tests/unit/guardrails-registry.test.ts`,
|
||
`tests/unit/prompt-injection-guard.test.ts`,
|
||
`tests/unit/guardrails/visionBridge.test.ts`).
|
||
|
||
## Testing
|
||
|
||
Use `resetGuardrailsForTests()` between tests to start from a known state.
|
||
Pass `{ registerDefaults: false }` to start with an empty registry and
|
||
register only the guardrails under test. The Vision Bridge guardrail accepts
|
||
dependency injection (`deps.getSettings`, `deps.callVisionModel`) so tests can
|
||
exercise the full flow without DB or network access.
|
||
|
||
## See Also
|
||
|
||
- `src/lib/guardrails/` — implementation
|
||
- `src/shared/utils/inputSanitizer.ts` — shared detector that powers
|
||
prompt-injection and PII masking
|
||
- `src/shared/constants/visionBridgeDefaults.ts` — Vision Bridge defaults and
|
||
forced-bridge model list
|
||
- `docs/architecture/RESILIENCE_GUIDE.md` — orthogonal layer (circuit breaker, cooldowns)
|
||
- `docs/reference/ENVIRONMENT.md` — full env var reference
|
||
|
||
## Injection-guard route coverage & red-team (Phase 8 · Block D)
|
||
|
||
The injection-guard (`createInjectionGuard` / `withInjectionGuard`) covers all routes
|
||
that accept user prompts. It respects `INJECTION_GUARD_MODE` (default `warn` = log only;
|
||
`block` = returns HTTP 400 `SECURITY_001`).
|
||
|
||
| Type | Routes | Default mode |
|
||
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||
| Text (existing) | `/v1/chat/completions`, `/v1/completions`, `/v1/relay/chat/completions` | warn |
|
||
| Generative | `/v1/messages`, `/v1/responses`, `/v1/images/generations`, `/v1/images/edits`, `/v1/videos/generations`, `/v1/music/generations`, `/v1/audio/speech` | warn |
|
||
| Data | `/v1/embeddings`, `/v1/rerank`, `/v1/search`, `/v1/moderations` | warn |
|
||
|
||
Text extraction (`extractMessageContents`) covers `messages`/`input`/`prompt`/`query`+`documents`/`instructions`/`system`.
|
||
|
||
**Red-team (nightly, `nightly-llm-security.yml`):** promptfoo validates that each route blocks
|
||
the OWASP-LLM corpus in `INJECTION_GUARD_MODE=block`; garak runs probes (skips without secret).
|
||
`moderations` is included for consistency — operators in block-mode can exempt it via
|
||
`resolveDisabledGuardrails`.
|
||
|
||
The nightly workflow (`.github/workflows/nightly-llm-security.yml`, cron + manual
|
||
dispatch) has two jobs:
|
||
|
||
- **`promptfoo-guard` (blocking)** — runs `promptfoo eval -c promptfooconfig.yaml`
|
||
with `INJECTION_GUARD_MODE=block`. Each adversarial case (e.g. "ignore all
|
||
previous instructions…", DAN-style jailbreaks) asserts the response carries
|
||
`error.code === "SECURITY_001"`, i.e. the guard actually rejected the request.
|
||
- **`garak` (advisory)** — runs garak `--probes promptinject,dan,leakreplay`
|
||
against a local OmniRoute instance (`http://localhost:20128/v1`). Gated on a
|
||
provider secret (`PROMPTFOO_PROVIDER_KEY`); skips gracefully and is suffixed
|
||
`|| true`, so it reports without failing CI.
|
||
|
||
Coverage of the guard helper (`createInjectionGuard` / `withInjectionGuard`)
|
||
spans every prompt-bearing `/v1` route; prompt text is pulled from
|
||
`messages`/`input`/`prompt`/`query`+`documents`/`instructions`/`system` by
|
||
`extractMessageContents()` in `src/shared/utils/inputSanitizer.ts`.
|