feat: guardrails init

This commit is contained in:
Alex committed 2026-08-11 00:09:56 +01:00
1 parent f7b3d3eacd
commit 64a6b81fbb
63 files changed
+10629 -93

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
/**
* Guardrail-specific e2e primitives.
*
* Two problems the guardrail specs have that no other spec has:
*
* 1. **They must pin the model's exact words.** The normal way to do that is
* a hash fixture under `scripts/e2e/mock_llm_fixtures/`, but DocsGPT's
* system prompt embeds `Today's date is <YYYY-MM-DD>` — the digest of the
* same question changes at midnight, so a committed fixture rots within a
* day. Instead the stub honours an in-band directive,
* `[[MOCK_LLM_EMIT:<base64url>]]`, embedded in the question (see the
* module docstring of `scripts/e2e/mock_llm.py`). `emitDirective` builds
* it. Base64 matters: it keeps the secret/PII literal out of the request
* itself, so an input-stage control cannot "helpfully" redact it and the
* persisted conversation prompt never carries it.
*
* 2. **They must land a sensitive value across a stream chunk boundary.**
* That is the whole point of `StreamingOutputGuard`'s lookback window
* (`application/guardrails/stream.py`): a detector that only ever sees the
* about-to-emit prefix would miss a match split over two deltas and leak
* the first half. `buildBoundarySplitAnswer` constructs an answer where a
* given literal provably straddles one of the stub's chunk boundaries,
* and returns the offsets so a spec can assert the straddle rather than
* assume it.
*/
import type { APIRequestContext } from '@playwright/test';
import { insertFixtureSource } from './agents.js';
/**
* Number of SSE deltas the stub splits an answer into. Mirrors
* `STREAM_CHUNK_COUNT` in `scripts/e2e/mock_llm.py`; if that changes, the
* boundary maths below changes with it.
*/
export const MOCK_STREAM_CHUNK_COUNT = 5;
/**
* Encode `content` as a `[[MOCK_LLM_EMIT:...]]` directive. Appending the
* result to a question makes the mock LLM answer with exactly `content`.
*/
export function emitDirective(content: string): string {
const b64 = Buffer.from(content, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `[[MOCK_LLM_EMIT:${b64}]]`;
}
/**
* Chunk boundaries the stub will split `content` on — the character offsets
* where one SSE delta ends and the next begins. Mirrors
* `_split_into_chunks` in `scripts/e2e/mock_llm.py`.
*/
export function mockChunkBoundaries(content: string): number[] {
const n = content.length;
const size = Math.max(
1,
Math.ceil(n / MOCK_STREAM_CHUNK_COUNT),
);
const boundaries: number[] = [];
for (let offset = size; offset < n; offset += size) {
boundaries.push(offset);
}
return boundaries;
}
const FILLER =
'Routine operational detail recorded during the quarterly review cycle. ';
/** Deterministic filler of exactly `n` characters. */
function filler(n: number): string {
if (n <= 0) return '';
return FILLER.repeat(Math.ceil(n / FILLER.length)).slice(0, n);
}
export interface BoundarySplitAnswer {
/** The full answer text to hand the mock LLM. */
content: string;
/** Offset of the sensitive literal inside `content`. */
start: number;
/** End offset (exclusive) of the sensitive literal. */
end: number;
/** The stub chunk boundary that falls strictly inside the literal. */
boundary: number;
/** Every chunk boundary, for assertion messages. */
boundaries: number[];
}
/**
* Build an answer of `totalChars` characters in which `secret` straddles a
* mock-LLM chunk boundary.
*
* `totalChars` must be a multiple of `MOCK_STREAM_CHUNK_COUNT` so the stub's
* `ceil(n / 5)` split lands on exact multiples of `totalChars / 5`; the
* secret is then centred on the first of those boundaries. The literal is
* surrounded by spaces so word-boundary-anchored detectors (`\bAKIA…`,
* `(?<!\w)term(?!\w)`) match it as written.
*
* Throws rather than returning a non-straddling payload — a guardrail spec
* that silently stopped exercising the boundary would still pass while
* testing nothing.
*/
export function buildBoundarySplitAnswer(
secret: string,
totalChars = 600,
): BoundarySplitAnswer {
if (totalChars % MOCK_STREAM_CHUNK_COUNT !== 0) {
throw new Error(
`buildBoundarySplitAnswer: totalChars must be a multiple of ` +
`${MOCK_STREAM_CHUNK_COUNT}, got ${totalChars}`,
);
}
const chunk = totalChars / MOCK_STREAM_CHUNK_COUNT;
const boundary = chunk;
const start = boundary - Math.floor(secret.length / 2);
const end = start + secret.length;
if (start <= 1 || end >= totalChars - 1) {
throw new Error(
`buildBoundarySplitAnswer: secret of length ${secret.length} does not ` +
`fit around boundary ${boundary} of a ${totalChars}-char answer`,
);
}
// A space immediately before and after the literal so `\b`-anchored
// detectors see it as a standalone token.
const head = `${filler(start - 1)} `;
const tail = ` ${filler(totalChars - end - 1)}`;
const content = head + secret + tail;
const boundaries = mockChunkBoundaries(content);
if (content.length !== totalChars) {
throw new Error(
`buildBoundarySplitAnswer: built ${content.length} chars, wanted ${totalChars}`,
);
}
if (content.indexOf(secret) !== start) {
throw new Error(
`buildBoundarySplitAnswer: secret landed at ${content.indexOf(secret)}, wanted ${start}`,
);
}
if (!boundaries.some((b) => b > start && b < end)) {
throw new Error(
`buildBoundarySplitAnswer: secret [${start},${end}) does not straddle ` +
`any boundary in [${boundaries.join(', ')}]`,
);
}
return { content, start, end, boundary, boundaries };
}
/**
* The normalized `guardrails` block as `AgentConfig.model_dump(mode="json")`
* renders it — i.e. what `agents.config` holds after a successful write.
*/
export interface GuardrailControlPayload {
check: string;
stage: string;
action?: string;
enabled?: boolean;
settings?: Record<string, unknown>;
}
export interface GuardrailsConfigPayload {
enabled?: boolean;
mode?: string;
fail_open?: boolean;
timeout_ms?: number;
block_message?: string;
controls?: GuardrailControlPayload[];
}
/**
* Publish a classic agent whose `config` carries `guardrails`.
*
* Published (not draft) on purpose: `StreamProcessor._configure_agent` only
* loads `agents.config` when the agent resolves to an API key, and a draft
* row has `key = NULL` — so guardrails configured on a draft never reach the
* engine. See the note in `guardrails-runtime.spec.ts`.
*
* The `sources` row is inserted directly rather than driven through
* /api/upload: publishing a classic agent requires *a* source, but these
* specs never retrieve from it (they pass `isNoneDoc`), so paying for Celery
* ingestion and a Faiss index would buy nothing. Same deviation, and same
* reasoning, as `helpers/agents.ts::publishClassicAgent`.
*/
export async function publishGuardrailAgent(
jsonApi: APIRequestContext,
multipartApi: APIRequestContext,
userId: string,
name: string,
guardrails: GuardrailsConfigPayload,
): Promise<{ id: string; key: string }> {
const sourceId = await insertFixtureSource(userId, `${name}-src`);
const promptRes = await jsonApi.post('/api/create_prompt', {
data: { name: `${name}-prompt`, content: 'Be concise.' },
});
if (promptRes.status() !== 200) {
throw new Error(
`create_prompt failed ${promptRes.status()}: ${await promptRes.text()}`,
);
}
const { id: promptId } = (await promptRes.json()) as { id: string };
const createRes = await multipartApi.post('/api/create_agent', {
multipart: {
name,
description: `e2e guardrail agent ${name}`,
status: 'published',
agent_type: 'classic',
chunks: '2',
retriever: 'classic',
prompt_id: promptId,
source: sourceId,
config: JSON.stringify({ guardrails }),
},
});
if (createRes.status() !== 201) {
throw new Error(
`create_agent with guardrails failed ${createRes.status()}: ${await createRes.text()}`,
);
}
const body = (await createRes.json()) as { id: string; key: string };
if (!body.id || !body.key) {
throw new Error(`create_agent returned no id/key: ${JSON.stringify(body)}`);
}
return body;
}
+56
View File
@@ -35,6 +35,62 @@ export async function streamOnce(
return match[1];
}
/** One `data: <json>` line of an SSE body. */
export interface SseFrame {
raw: string;
// The backend emits many distinct payload shapes on one channel.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
}
/**
* Parse an SSE-over-text body into structured frames. Each non-empty
* `data: <json>` line becomes a frame with a parsed `data` payload. Lines
* that don't parse as JSON (e.g. `data: [DONE]`) are surfaced with
* `data = null` and the raw text preserved for the caller.
*/
export function parseSseFrames(text: string): SseFrame[] {
const frames: SseFrame[] = [];
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const payload = trimmed.slice('data:'.length).trim();
if (!payload) continue;
try {
frames.push({ raw: payload, data: JSON.parse(payload) });
} catch {
frames.push({ raw: payload, data: null });
}
}
return frames;
}
/**
* POST /stream, drain the SSE body, and return parsed frames plus the raw
* text and status. `APIRequestContext` buffers the whole body, so by the
* time this resolves the server-side generator has emitted its terminal
* `{"type":"end"}` frame (or errored).
*/
export async function streamFrames(
api: APIRequestContext,
body: Record<string, unknown>,
): Promise<{ status: number; frames: SseFrame[]; text: string }> {
const res = await api.post('/stream', { data: body });
const text = await res.text();
return { status: res.status(), frames: parseSseFrames(text), text };
}
/**
* Concatenate every `{"type":"answer"}` delta in order — the exact text the
* user ends up seeing in the chat bubble.
*/
export function answerText(frames: SseFrame[]): string {
return frames
.filter((f) => f.data?.type === 'answer')
.map((f) => String(f.data.answer ?? ''))
.join('');
}
/**
* Start a /stream POST and return a promise resolving to the HTTP status.
* For races where the caller wants to kick off a stream and concurrently
@@ -0,0 +1,272 @@
/**
* tier-b · the guardrail activity panel on /agents/logs/:agentId.
*
* Silent break covered: the audit journal existed for a while with no UI and
* no caller — `userService.getGuardrailEvents` had zero references — so a
* flagged turn was only discoverable via psql. An owner who cannot see which
* control fired cannot tune it, and "auditability" is the main thing this
* feature claims to buy. These specs assert the panel actually renders real
* rows produced by a real turn, not fixtures.
*
* The second thing covered is the four-way totals split. Blocked / redacted /
* flagged / not-evaluated are different product problems: "we refused",
* "we masked something", "we noticed", and "the check never ran". Collapsing
* them into one "violations" number is the mistake most vendors make, and
* `not_evaluated` in particular is how a silently-broken detector shows up.
*/
import * as playwright from '@playwright/test';
const { expect, test } = playwright;
import type { APIRequestContext } from '@playwright/test';
import { multipartAuthedRequest } from '../../helpers/agents.js';
import { authedRequest } from '../../helpers/api.js';
import { newUserContext, signJwt } from '../../helpers/auth.js';
import { pg } from '../../helpers/db.js';
import {
emitDirective,
publishGuardrailAgent,
} from '../../helpers/guardrails.js';
import { resetDb } from '../../helpers/reset.js';
import { streamFrames } from '../../helpers/streaming.js';
const BANNED = 'zebrafish-protocol';
/** An agent that blocks one term on input and flags emails on output. */
function config() {
return {
enabled: true,
mode: 'scan_all' as const,
fail_open: true,
timeout_ms: 2000,
block_message: 'Blocked by the e2e panel policy.',
controls: [
{
check: 'denylist',
stage: 'input',
action: 'block',
enabled: true,
settings: { terms: [BANNED], match: 'word', case_sensitive: false },
},
{
check: 'pii',
stage: 'output',
action: 'flag',
enabled: true,
settings: { entities: ['EMAIL'] },
},
],
};
}
/** A turn whose question trips the input denylist. */
function runBlockedTurn(api: APIRequestContext, agentId: string) {
return streamFrames(api, {
question: `tell me about the ${BANNED} please`,
agent_id: agentId,
history: '[]',
isNoneDoc: true,
});
}
/** A turn whose answer carries an email, tripping the output pii flag. */
function runFlaggedTurn(api: APIRequestContext, agentId: string) {
return streamFrames(api, {
question: `summarise this ${emitDirective(
'You can reach the team at ada@example.com any time you like.',
)}`,
agent_id: agentId,
history: '[]',
isNoneDoc: true,
});
}
test.describe('tier-b · guardrail activity panel', () => {
test.beforeEach(async () => {
await resetDb();
});
test('a blocked turn and a flagged turn both appear in the panel with the right totals', async ({
browser,
}) => {
const sub = `e2e-gr-panel-${Date.now()}`;
const token = signJwt(sub);
const json = await authedRequest(playwright, token);
const multipart = await multipartAuthedRequest(token);
try {
const { id: agentId } = await publishGuardrailAgent(
json,
multipart,
sub,
'panel agent',
config(),
);
await runBlockedTurn(json, agentId);
await runFlaggedTurn(json, agentId);
// The journal is the source of truth the panel reads; assert it first so
// a UI failure below is unambiguously a UI failure.
const { rows } = await pg.query<{ action: string; outcome: string }>(
`SELECT action, outcome FROM guardrail_events
WHERE agent_id = $1::uuid ORDER BY created_at`,
[agentId],
);
expect(
rows.length,
`expected journal rows for two triggering turns, got ${JSON.stringify(rows)}`,
).toBeGreaterThanOrEqual(2);
const { context } = await newUserContext(browser, { sub });
const page = await context.newPage();
await page.goto(`/agents/logs/${agentId}`);
const panel = page.getByTestId('guardrail-events');
await expect(panel).toBeVisible();
// Four separate tiles, not one aggregate count.
await expect(page.getByTestId('guardrail-stat-blocked')).toContainText(
'1',
);
await expect(page.getByTestId('guardrail-stat-flagged')).toContainText(
'1',
);
await expect(
page.getByTestId('guardrail-stat-not-evaluated'),
).toContainText('0');
// Both decisions listed, and the check that fired is named.
const table = page.getByTestId('guardrail-events-rows');
await expect(table.locator('tr')).toHaveCount(rows.length);
await expect(table).toContainText('denylist');
await expect(table).toContainText('pii');
// Per-check breakdown is what tells an operator which control to tune.
await expect(page.getByTestId('guardrail-by-check')).toBeVisible();
await context.close();
} finally {
await json.dispose();
await multipart.dispose();
}
});
test('the check filter narrows the table to a single control', async ({
browser,
}) => {
const sub = `e2e-gr-filter-${Date.now()}`;
const token = signJwt(sub);
const json = await authedRequest(playwright, token);
const multipart = await multipartAuthedRequest(token);
try {
const { id: agentId } = await publishGuardrailAgent(
json,
multipart,
sub,
'filter agent',
config(),
);
await runBlockedTurn(json, agentId);
await runFlaggedTurn(json, agentId);
const { context } = await newUserContext(browser, { sub });
const page = await context.newPage();
await page.goto(`/agents/logs/${agentId}`);
const table = page.getByTestId('guardrail-events-rows');
await expect(table).toContainText('denylist');
await expect(table).toContainText('pii');
await page.getByTestId('guardrail-events-check-filter').click();
await page.getByRole('option', { name: 'denylist' }).click();
await expect(table).toContainText('denylist');
await expect(table).not.toContainText('pii');
await context.close();
} finally {
await json.dispose();
await multipart.dispose();
}
});
test('an agent that has never tripped a guardrail shows an explicit empty state', async ({
browser,
}) => {
const sub = `e2e-gr-empty-${Date.now()}`;
const token = signJwt(sub);
const json = await authedRequest(playwright, token);
const multipart = await multipartAuthedRequest(token);
try {
const { id: agentId } = await publishGuardrailAgent(
json,
multipart,
sub,
'quiet agent',
config(),
);
const { context } = await newUserContext(browser, { sub });
const page = await context.newPage();
await page.goto(`/agents/logs/${agentId}`);
await expect(page.getByTestId('guardrail-events-empty')).toBeVisible();
await expect(page.getByTestId('guardrail-stat-blocked')).toContainText(
'0',
);
await context.close();
} finally {
await json.dispose();
await multipart.dispose();
}
});
test('the panel is scoped to one agent — another agent’s decisions do not appear', async ({
browser,
}) => {
const sub = `e2e-gr-scope-${Date.now()}`;
const token = signJwt(sub);
const json = await authedRequest(playwright, token);
const multipart = await multipartAuthedRequest(token);
try {
const noisy = await publishGuardrailAgent(
json,
multipart,
sub,
'noisy agent',
config(),
);
const quiet = await publishGuardrailAgent(
json,
multipart,
sub,
'quiet agent',
config(),
);
await runBlockedTurn(json, noisy.id);
const { context } = await newUserContext(browser, { sub });
const page = await context.newPage();
await page.goto(`/agents/logs/${quiet.id}`);
await expect(page.getByTestId('guardrail-stat-blocked')).toContainText(
'0',
{
timeout: 10_000,
},
);
await expect(page.getByTestId('guardrail-events-empty')).toBeVisible();
await context.close();
} finally {
await json.dispose();
await multipart.dispose();
}
});
});
File diff suppressed because it is too large. Load diff
+875
View File
@@ -0,0 +1,875 @@
/**
* Tier-B · agent guardrails: config contract, catalog, and builder UI.
*
* Guardrails are a *configuration* feature before they are a runtime one: an
* operator ticks boxes in the agent builder and trusts that what they saw is
* what the engine will run. Everything between that click and
* `GuardrailEngine.evaluate` is silent — `GuardrailsConfig.parse` is
* deliberately lenient on read (`application/guardrails/config.py:141-149`)
* and falls back to an all-defaults, **disabled** config rather than raising.
* That is the right call for a hot streaming path, and it is exactly what
* makes a bad write invisible: a control that failed to persist, or persisted
* in a shape the parser rejects, does not error at run time — it silently
* protects nothing, forever, while the builder keeps rendering the checkbox
* as ticked.
*
* // Silent-break covered #1: a guardrail control that round-trips through
* // /api/create_agent → agents.config → /api/get_agent in a shape the
* // runtime parser then discards. Asserted against the JSONB column itself,
* // not just the API echo, because the API echo and the runtime read the
* // same row through different code paths.
*
* // Silent-break covered #2: a malformed control accepted with a 200. Every
* // rejection case below is a configuration an operator could plausibly
* // write and reasonably believe was enforcing something —
* // `require_approval` on user input (there is nothing to approve), `redact`
* // on a check that reports no spans, a duplicate control that shadows the
* // stricter twin. Each must 400 AND leave no row behind.
*
* // Silent-break covered #3: the builder writes state the backend then
* // normalises away, so a reload shows the operator something different
* // from what they saved.
*
* Runtime behaviour (blocking, redaction over a live SSE stream, the audit
* journal) lives in `guardrails-runtime.spec.ts`.
*/
import * as playwright from '@playwright/test';
const { expect, test } = playwright;
import type { APIRequestContext } from '@playwright/test';
import { multipartAuthedRequest } from '../../helpers/agents.js';
import { authedRequest } from '../../helpers/api.js';
import { newUserContext, signJwt } from '../../helpers/auth.js';
import { countRows, pg } from '../../helpers/db.js';
import { resetDb } from '../../helpers/reset.js';
/** Every registry key `application/guardrails/checks/__init__.py` registers. */
const EXPECTED_CHECKS = [
'denylist',
'groundedness',
'injection',
'moderation',
'pii',
'policy',
'secrets',
'tool_policy',
'topic',
'url',
];
const VALID_STAGES = [
'input',
'retrieval',
'tool_call',
'tool_result',
'output',
];
/**
* Defaults `GuardrailsConfig` fills in for anything the caller omitted
* (`application/guardrails/config.py`). Asserted by exact deep-equality
* below rather than `toMatchObject`, because what ends up in the JSONB
* column IS the contract: a field silently appearing, disappearing, or
* changing default is a change to every stored agent's behaviour, and it
* should cost one deliberate edit here to acknowledge it.
*/
const CONFIG_DEFAULTS = {
enabled: false,
mode: 'monitor_only',
fail_open: true,
timeout_ms: 2000,
block_message: "Sorry, I can't help with that request.",
controls: [] as unknown[],
};
/** Read the raw `agents.config` JSONB straight out of Postgres. */
async function dbAgentConfig(
agentId: string,
): Promise<Record<string, unknown> | null> {
const { rows } = await pg.query<{ config: Record<string, unknown> | null }>(
'SELECT config FROM agents WHERE id = CAST($1 AS uuid)',
[agentId],
);
return rows[0]?.config ?? null;
}
/**
* Create a draft classic agent carrying `config`, via the multipart form
* path the builder actually uses (`NewAgent.tsx` appends `config` to a
* FormData). Returns the raw response so rejection tests can assert on it.
*/
function createAgentWithConfig(
ctx: APIRequestContext,
name: string,
config: unknown,
) {
return ctx.post('/api/create_agent', {
multipart: {
name,
status: 'draft',
agent_type: 'classic',
chunks: '2',
config: JSON.stringify(config),
},
});
}
test.describe('tier-b · guardrails config contract', () => {
test.beforeEach(async () => {
await resetDb();
});
test('config round-trips create → get_agent → agents.config JSONB, and update_agent replaces it wholesale', async () => {
const sub = `e2e-guardrails-roundtrip-${Date.now()}`;
const token = signJwt(sub);
const multipart = await multipartAuthedRequest(token);
const json = await authedRequest(playwright, token);
try {
// A deliberately PARTIAL config: only the fields an operator would
// actually set. Everything else must come back filled with the
// Pydantic defaults, and the denylist settings must come back
// normalised (deduped terms, `match`/`case_sensitive` materialised) —
// that normalisation is what makes a stored control self-describing.
const createRes = await createAgentWithConfig(multipart, 'roundtrip', {
guardrails: {
enabled: true,
mode: 'scan_all',
block_message: 'Not allowed here.',
controls: [
{
check: 'denylist',
stage: 'input',
action: 'block',
settings: { terms: ['alpha', 'beta', 'alpha'] },
},
{ check: 'secrets', stage: 'output', action: 'redact' },
],
},
});
expect(
createRes.status(),
`create_agent with guardrails should be 201, got ${createRes.status()} ${await createRes.text()}`,
).toBe(201);
const agentId = ((await createRes.json()) as { id: string }).id;
const expectedAfterCreate = {
guardrails: {
...CONFIG_DEFAULTS,
enabled: true,
mode: 'scan_all',
block_message: 'Not allowed here.',
controls: [
{
check: 'denylist',
stage: 'input',
action: 'block',
enabled: true,
settings: {
terms: ['alpha', 'beta'],
match: 'word',
case_sensitive: false,
},
},
{
check: 'secrets',
stage: 'output',
action: 'redact',
enabled: true,
settings: {},
},
],
},
};
// The API echo...
const getRes = await json.get(`/api/get_agent?id=${agentId}`);
expect(getRes.status()).toBe(200);
const fetched = (await getRes.json()) as { config: unknown };
expect(
fetched.config,
'GET /api/get_agent must echo the normalized guardrails config',
).toEqual(expectedAfterCreate);
// ...and the column the runtime actually reads. These are different
// code paths (`_format_agent_output` vs `StreamProcessor`), so both
// are asserted.
expect(
await dbAgentConfig(agentId),
'agents.config JSONB must hold the normalized guardrails config',
).toEqual(expectedAfterCreate);
// ---- update replaces, it does not merge -------------------------
// Drop the denylist control entirely, flip the mode down to
// monitor_only, and tighten fail_open. A merge-instead-of-replace bug
// would leave the removed control in place — the operator thinks they
// turned a block off and it is still blocking.
const updateRes = await json.put(`/api/update_agent/${agentId}`, {
data: {
config: {
guardrails: {
enabled: true,
mode: 'monitor_only',
fail_open: false,
timeout_ms: 5000,
controls: [
{ check: 'secrets', stage: 'output', action: 'redact' },
],
},
},
},
});
expect(
updateRes.status(),
`update_agent with guardrails should be 200, got ${updateRes.status()} ${await updateRes.text()}`,
).toBe(200);
const expectedAfterUpdate = {
guardrails: {
...CONFIG_DEFAULTS,
enabled: true,
mode: 'monitor_only',
fail_open: false,
timeout_ms: 5000,
// `block_message` was not supplied on update, so it falls back to
// the Pydantic default rather than the previously-stored value —
// config is a document, not a patch.
controls: [
{
check: 'secrets',
stage: 'output',
action: 'redact',
enabled: true,
settings: {},
},
],
},
};
expect(
await dbAgentConfig(agentId),
'update_agent must replace agents.config wholesale, not merge into it',
).toEqual(expectedAfterUpdate);
const getAfter = await json.get(`/api/get_agent?id=${agentId}`);
expect(getAfter.status()).toBe(200);
expect(
((await getAfter.json()) as { config: unknown }).config,
'get_agent must reflect the updated config',
).toEqual(expectedAfterUpdate);
} finally {
await multipart.dispose();
await json.dispose();
}
});
test('an agent created without a config gets an empty JSONB object, not NULL', async () => {
const sub = `e2e-guardrails-noconfig-${Date.now()}`;
const token = signJwt(sub);
const multipart = await multipartAuthedRequest(token);
const json = await authedRequest(playwright, token);
try {
const res = await multipart.post('/api/create_agent', {
multipart: {
name: 'no config at all',
status: 'draft',
agent_type: 'classic',
chunks: '2',
},
});
expect(res.status()).toBe(201);
const agentId = ((await res.json()) as { id: string }).id;
// `agents.config` is NOT NULL DEFAULT '{}'::jsonb (alembic
// 0029_agent_guardrails). A NULL here would make every read path do
// `or {}` gymnastics and would break `AgentConfig.parse`'s isinstance
// check in a way that is invisible until a control is added.
expect(
await dbAgentConfig(agentId),
'agents.config must default to {} so existing agents parse as guardrails-disabled',
).toEqual({});
const getRes = await json.get(`/api/get_agent?id=${agentId}`);
expect(getRes.status()).toBe(200);
expect(((await getRes.json()) as { config: unknown }).config).toEqual({});
} finally {
await multipart.dispose();
await json.dispose();
}
});
test('an empty-string config is treated as "not supplied" and leaves the column at {}', async () => {
const sub = `e2e-guardrails-emptystr-${Date.now()}`;
const token = signJwt(sub);
const multipart = await multipartAuthedRequest(token);
try {
// The builder sends `config` on every save. An older client, or a
// form field that never got populated, sends "". That must not be a
// 400 — `normalize_agent_config` returns None and the field is
// dropped (`application/api/user/agents/routes.py:384-385`).
const res = await multipart.post('/api/create_agent', {
multipart: {
name: 'empty string config',
status: 'draft',
agent_type: 'classic',
chunks: '2',
config: '',
},
});
expect(
res.status(),
`empty config string must be accepted, got ${res.status()} ${await res.text()}`,
).toBe(201);
const agentId = ((await res.json()) as { id: string }).id;
expect(await dbAgentConfig(agentId)).toEqual({});
} finally {
await multipart.dispose();
}
});
});
test.describe('tier-b · guardrails strict validation on write', () => {
test.beforeEach(async () => {
await resetDb();
});
/**
* Each case is a config an operator could plausibly write and believe was
* protecting them. `expectedMessage` is asserted as a substring so the
* test pins the *diagnosis*, not just the status code — a 400 that says
* "invalid" teaches nobody which box to untick.
*/
const REJECTIONS: Array<{
label: string;
config: unknown;
expectedMessage: string;
}> = [
{
label: 'unknown check name',
config: {
guardrails: {
enabled: true,
controls: [{ check: 'no_such_check', stage: 'input', action: 'flag' }],
},
},
expectedMessage: "unknown check 'no_such_check'",
},
{
label: 'require_approval at a stage that has no approval flow (input)',
config: {
guardrails: {
enabled: true,
controls: [
{
check: 'denylist',
stage: 'input',
action: 'require_approval',
settings: { terms: ['x'] },
},
],
},
},
expectedMessage:
"action 'require_approval' is not valid at stage 'input'",
},
{
label: 'redact on a check that reports no spans (groundedness)',
config: {
guardrails: {
enabled: true,
controls: [
{ check: 'groundedness', stage: 'output', action: 'redact' },
],
},
},
expectedMessage: "check 'groundedness' cannot redact",
},
{
label: 'a check bound to a stage it does not support',
config: {
guardrails: {
enabled: true,
controls: [
{ check: 'groundedness', stage: 'input', action: 'flag' },
],
},
},
expectedMessage:
"check 'groundedness' does not support stage 'input'",
},
{
label: 'duplicate control for the same check+stage',
config: {
guardrails: {
enabled: true,
controls: [
{
check: 'denylist',
stage: 'input',
action: 'flag',
settings: { terms: ['a'] },
},
{
check: 'denylist',
stage: 'input',
action: 'block',
settings: { terms: ['b'] },
},
],
},
},
expectedMessage:
"duplicate control for check 'denylist' at stage 'input'",
},
{
label: 'denylist with an empty term list',
config: {
guardrails: {
enabled: true,
controls: [
{
check: 'denylist',
stage: 'input',
action: 'flag',
settings: { terms: [] },
},
],
},
},
expectedMessage: 'terms must be a non-empty list',
},
{
label: 'pii with an entity the detector has no pattern for',
config: {
guardrails: {
enabled: true,
controls: [
{
check: 'pii',
stage: 'output',
action: 'redact',
settings: { entities: ['NOT_A_THING'] },
},
],
},
},
expectedMessage: 'unknown PII entities: NOT_A_THING',
},
{
label: 'url policy with neither an allow nor a block list',
config: {
guardrails: {
enabled: true,
controls: [
{ check: 'url', stage: 'output', action: 'redact', settings: {} },
],
},
},
expectedMessage: 'provide allow_hosts or block_hosts',
},
{
label: 'an unknown enforcement mode',
config: { guardrails: { enabled: true, mode: 'yolo' } },
expectedMessage: 'mode must be one of',
},
{
label: 'a timeout below the floor',
config: { guardrails: { enabled: true, timeout_ms: 5 } },
expectedMessage: 'must be >= 100',
},
{
label: 'a typo\'d key at the top level of config',
config: { guardrails: { enabled: true }, guardrail: {} },
expectedMessage: 'Extra inputs are not permitted',
},
{
label: 'a typo\'d key inside a control',
config: {
guardrails: {
enabled: true,
controls: [
{
check: 'secrets',
stage: 'output',
action: 'flag',
setttings: {},
},
],
},
},
expectedMessage: 'Extra inputs are not permitted',
},
{
label: 'config that is not a JSON object at all',
config: ['not', 'an', 'object'],
expectedMessage: 'config must be a JSON object',
},
];
for (const { label, config, expectedMessage } of REJECTIONS) {
test(`create_agent rejects ${label} with a 400 and writes no row`, async () => {
const sub = `e2e-guardrails-reject-${Date.now()}-${Math.random()
.toString(36)
.slice(2, 8)}`;
const token = signJwt(sub);
const ctx = await multipartAuthedRequest(token);
try {
const res = await createAgentWithConfig(ctx, `reject ${label}`, config);
const body = await res.text();
expect(res.status(), `expected 400 for ${label}, body: ${body}`).toBe(
400,
);
expect(
body,
`the 400 for "${label}" must name the problem; got: ${body}`,
).toContain(expectedMessage);
// Nothing may persist. A rejected config that still creates the
// agent (minus its guardrails) is the worst outcome: the operator
// sees an agent, assumes the save worked, and it is unguarded.
expect(
await countRows('agents', { sql: 'user_id = $1', params: [sub] }),
`rejected create for "${label}" must not leave an agents row behind`,
).toBe(0);
} finally {
await ctx.dispose();
}
});
}
test('a rejected update_agent leaves the previously-stored config untouched', async () => {
const sub = `e2e-guardrails-badupdate-${Date.now()}`;
const token = signJwt(sub);
const multipart = await multipartAuthedRequest(token);
const json = await authedRequest(playwright, token);
try {
const createRes = await createAgentWithConfig(multipart, 'good then bad', {
guardrails: {
enabled: true,
mode: 'scan_all',
controls: [{ check: 'secrets', stage: 'output', action: 'block' }],
},
});
expect(createRes.status()).toBe(201);
const agentId = ((await createRes.json()) as { id: string }).id;
const before = await dbAgentConfig(agentId);
expect(before).not.toBeNull();
const badUpdate = await json.put(`/api/update_agent/${agentId}`, {
data: {
config: {
guardrails: {
enabled: true,
controls: [
{ check: 'no_such_check', stage: 'input', action: 'flag' },
],
},
},
},
});
expect(badUpdate.status()).toBe(400);
// The pre-existing block control must survive a failed save. If the
// route wrote `{}` before validating, an operator's typo would quietly
// disarm a working guardrail.
expect(
await dbAgentConfig(agentId),
'a 400 on update must not clobber the stored guardrails config',
).toEqual(before);
} finally {
await multipart.dispose();
await json.dispose();
}
});
});
test.describe('tier-b · guardrails catalog endpoint', () => {
test.beforeEach(async () => {
await resetDb();
});
test('GET /api/guardrails/catalog describes all ten checks with the metadata the builder renders', async () => {
const sub = `e2e-guardrails-catalog-${Date.now()}`;
const token = signJwt(sub);
const api = await authedRequest(playwright, token);
try {
const res = await api.get('/api/guardrails/catalog');
expect(res.status()).toBe(200);
const body = (await res.json()) as {
success: boolean;
enabled: boolean;
checks: Array<{
name: string;
label: string;
description: string;
stages: string[];
supports_redaction: boolean;
latency_hint_ms: number;
remote: boolean;
available: boolean;
}>;
stages: string[];
modes: string[];
actions_by_stage: Record<string, string[]>;
default_block_message: string;
pii_entities: string[];
default_pii_entities: string[];
moderation_categories: string[];
floor: unknown;
};
expect(body.success).toBe(true);
expect(body.enabled).toBe(true);
expect(
body.checks.map((c) => c.name).sort(),
'every registered check must be advertised — a check missing from the ' +
'catalog is a check no operator can ever switch on',
).toEqual(EXPECTED_CHECKS);
for (const check of body.checks) {
expect(
typeof check.latency_hint_ms,
`${check.name}.latency_hint_ms must be a number so the builder can ` +
'price the check before it is enabled',
).toBe('number');
expect(check.latency_hint_ms).toBeGreaterThan(0);
expect(typeof check.supports_redaction).toBe('boolean');
expect(typeof check.available).toBe('boolean');
expect(typeof check.remote).toBe('boolean');
expect(check.label.length).toBeGreaterThan(0);
expect(check.description.length).toBeGreaterThan(0);
expect(
Array.isArray(check.stages) && check.stages.length > 0,
`${check.name}.stages must be a non-empty list`,
).toBe(true);
for (const stage of check.stages) {
expect(
VALID_STAGES,
`${check.name} advertises unknown stage ${stage}`,
).toContain(stage);
}
}
// The builder filters the action dropdown by `supports_redaction`
// (GuardrailsSection.tsx:511-515); if the catalog lied, the UI would
// offer `redact` on a span-less check and the save would 400.
const byName = Object.fromEntries(body.checks.map((c) => [c.name, c]));
expect(byName.secrets.supports_redaction).toBe(true);
expect(byName.pii.supports_redaction).toBe(true);
expect(byName.denylist.supports_redaction).toBe(true);
expect(byName.groundedness.supports_redaction).toBe(false);
expect(byName.injection.supports_redaction).toBe(false);
// Local pattern checks must not be advertised as remote — a false
// `remote` flips `StreamingOutputGuard` into sentence-segmented mode
// and changes when text is released.
expect(byName.secrets.remote).toBe(false);
expect(byName.pii.remote).toBe(false);
expect(byName.moderation.remote).toBe(true);
// `tool_policy` only makes sense at the tool gate.
expect(byName.tool_policy.stages).toEqual(['tool_call']);
expect(byName.groundedness.stages).toEqual(['output']);
expect(body.stages).toEqual(VALID_STAGES);
expect(body.modes).toEqual([
'monitor_only',
'background_scan',
'dangerous_tools_only',
'scan_all',
]);
// `require_approval` has a resolution path only at the tool gate.
for (const [stage, actions] of Object.entries(body.actions_by_stage)) {
expect(VALID_STAGES).toContain(stage);
if (stage === 'tool_call') {
expect(actions).toEqual(['block', 'flag', 'require_approval']);
} else {
expect(
actions,
`stage ${stage} must not advertise require_approval`,
).not.toContain('require_approval');
}
}
expect(body.default_block_message).toBe(
"Sorry, I can't help with that request.",
);
expect(body.pii_entities).toEqual([
'CREDIT_CARD',
'EMAIL',
'IBAN',
'IPV4',
'PHONE',
'US_SSN',
]);
expect(body.default_pii_entities.length).toBeGreaterThan(0);
for (const entity of body.default_pii_entities) {
expect(
body.pii_entities,
`default PII entity ${entity} is not in the advertised entity list`,
).toContain(entity);
}
expect(body.moderation_categories.length).toBeGreaterThan(0);
// No instance floor is configured in the e2e env, so the builder must
// be told there is nothing to lock.
expect(body.floor).toBeNull();
} finally {
await api.dispose();
}
});
test('the catalog is not readable without a token', async () => {
const api = await playwright.request.newContext({
baseURL: process.env.API_URL ?? 'http://127.0.0.1:7099',
});
try {
const res = await api.get('/api/guardrails/catalog');
expect(
res.status(),
'the guardrail catalog must require authentication',
).toBe(401);
} finally {
await api.dispose();
}
});
});
test.describe('tier-b · guardrails builder UI', () => {
test.beforeEach(async () => {
await resetDb();
});
test('enabling a check in the agent builder persists it, and a reload rehydrates exactly what was saved', async ({
browser,
}) => {
const { context, token } = await newUserContext(browser);
const multipart = await multipartAuthedRequest(token);
try {
// Seed a DRAFT agent through the API so the builder opens in `draft`
// mode, where "Save Draft" is available without a source/prompt.
const createRes = await multipart.post('/api/create_agent', {
multipart: {
name: 'ui guardrails agent',
description: 'built via the UI spec',
status: 'draft',
agent_type: 'classic',
chunks: '2',
},
});
expect(createRes.status()).toBe(201);
const agentId = ((await createRes.json()) as { id: string }).id;
expect(await dbAgentConfig(agentId)).toEqual({});
const page = await context.newPage();
await page.goto(`/agents/edit/${agentId}`);
const section = page.getByTestId('guardrails-section');
await expect(section).toBeVisible();
// Collapsed by default — the whole point of the section is that it
// costs nothing to ignore.
await expect(page.getByTestId('guardrails-enabled')).toBeHidden();
await page.getByTestId('guardrails-toggle').click();
const enableSwitch = page.getByTestId('guardrails-enabled');
await expect(enableSwitch).toBeVisible();
await expect(enableSwitch).toHaveAttribute('data-state', 'unchecked');
await enableSwitch.click();
await expect(enableSwitch).toHaveAttribute('data-state', 'checked');
// The catalog drives the check list; the cards only exist once
// /api/guardrails/catalog has resolved.
const secretsCard = page.getByTestId('guardrail-check-secrets');
await expect(secretsCard).toBeVisible();
// Attach `secrets` to the answer stage, then promote it from the
// default `flag` to `redact`.
await page.getByTestId('guardrail-stage-secrets-output').click();
const actionTrigger = page.getByTestId('guardrail-action-secrets-output');
await expect(actionTrigger).toBeVisible();
await expect(actionTrigger).toHaveText(/flag only/i);
await actionTrigger.click();
await page.getByRole('option', { name: 'Redact' }).click();
await expect(actionTrigger).toHaveText(/redact/i);
// Enforce rather than monitor, and set a custom refusal message.
const modeTrigger = page.getByTestId('guardrails-mode');
await modeTrigger.click();
await page.getByRole('option', { name: /enforce everywhere/i }).click();
await expect(modeTrigger).toHaveText(/enforce everywhere/i);
const blockMessage = page.getByTestId('guardrails-block-message');
await blockMessage.fill('Refused by the UI spec.');
const saveRequest = page.waitForResponse(
(r) =>
r.url().includes('/api/update_agent/') &&
r.request().method() === 'PUT',
{ timeout: 20_000 },
);
await page.getByRole('button', { name: /save draft/i }).click();
const saveRes = await saveRequest;
expect(
saveRes.status(),
`saving the agent from the builder should be 200, got ${saveRes.status()}`,
).toBe(200);
// ---- the row, not the redux store -------------------------------
const stored = (await dbAgentConfig(agentId)) as {
guardrails: {
enabled: boolean;
mode: string;
block_message: string;
controls: Array<{
check: string;
stage: string;
action: string;
enabled: boolean;
}>;
};
};
expect(
stored?.guardrails,
'the builder must write a guardrails block into agents.config',
).toBeTruthy();
expect(stored.guardrails.enabled).toBe(true);
expect(stored.guardrails.mode).toBe('scan_all');
expect(stored.guardrails.block_message).toBe('Refused by the UI spec.');
expect(stored.guardrails.controls).toHaveLength(1);
expect(stored.guardrails.controls[0]).toMatchObject({
check: 'secrets',
stage: 'output',
action: 'redact',
enabled: true,
});
// ---- reload rehydrates the same state ---------------------------
// This is the silent break: the backend normalises on write, so a
// builder that renders from its own optimistic state would look right
// until the operator came back tomorrow.
await page.reload();
await expect(page.getByTestId('guardrails-section')).toBeVisible();
await page.getByTestId('guardrails-toggle').click();
await expect(page.getByTestId('guardrails-enabled')).toHaveAttribute(
'data-state',
'checked',
);
await expect(page.getByTestId('guardrails-mode')).toHaveText(
/enforce everywhere/i,
);
await expect(page.getByTestId('guardrails-block-message')).toHaveValue(
'Refused by the UI spec.',
);
await expect(
page.getByTestId('guardrail-action-secrets-output'),
'the saved action must survive a reload — a control that rehydrates ' +
'as "flag only" silently downgrades an operator-chosen redaction',
).toHaveText(/redact/i);
} finally {
await multipart.dispose();
await context.close();
}
});
});