v0.41.35.0 feat(guardrails): vendor-neutral content guardrail seams (supersedes #1652) (#1660)

* feat(guardrails): vendor-neutral content guardrail seams

Expose observe-only guardrail seams at the five boundaries where external
content enters the retrieval layer and the LLM gateway, so a content firewall
(prompt-injection / RAG-poison detector, PII scrubber, etc.) can be hooked in
without binding GBrain to any specific vendor.

New module src/core/guardrails.ts:
  - runGuardrails({ hook, content, metadata }) -> void
  - registerGuardrailProvider / unregisterGuardrailProvider
  - hasGuardrails() fast-path guard for hot paths

Seams (all observe-only, fail-open, inline-await, inert by default):
  - file_storage.markdown  (import-file.ts importFromContent)
  - file_storage.code      (import-file.ts importCodeFile)
  - ai_gateway.chat        (gateway.ts chat, last user message only)
  - ai_gateway.expand      (gateway.ts expand)
  - ai_gateway.tool_input  (gateway.ts toolLoop, before pending-persist)

Invariants enforced by test/guardrails.test.ts (14 tests):
  - returns void; callers never branch on a verdict
  - provider throw/reject is swallowed (fail-open isolation)
  - slow async provider is awaited before resolving (inline)
  - zero providers => no-op; empty/blank content short-circuits
  - content + metadata passed through unmutated; idempotent by id

Hooks pass only the ingest/user-facing payload (md/code body, last user
message, expansion query, tool input). Never system prompts, full history,
tool output, LLM output, embeddings, or multimodal payloads.

Docs: docs/guardrails.md (contract, seam table, provider authoring guide).
OSS ships inert; vendors register a provider in their own package.

* chore: bump version and changelog (v0.41.35.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents <agent@garrytan.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-30 12:34:43 -07:00
committed by GitHub
co-authored by garrytan-agents Claude Opus 4.8
parent d6db3f0ce3
commit 0b2a26a31d
8 changed files with 656 additions and 2 deletions
+97
View File
@@ -2,6 +2,103 @@
All notable changes to GBrain will be documented in this file.
## [0.41.35.0] - 2026-05-30
**GBrain now has five built-in spots where an outside content checker can watch
what flows in, and the open-source build ships with all five turned off.**
If you run a brain, content arrives from places you don't fully control: a
markdown note you imported, a code file you synced, a question typed at the LLM,
a tool the agent decided to call. Any one of those can carry a prompt-injection
payload or something you'd rather not store. Until now there was no clean place
to hand that content to a checker (a content firewall, a PII scrubber, an
injection detector) before GBrain acted on it.
This release adds those clean places. Five hook points: the two file-import
boundaries (markdown and code, right before the content gets chunked, embedded,
and saved) and three LLM gateway boundaries (the user's chat message, the
search-expansion query, and a tool call before it runs). A checker registered at
any of these gets to see the content at the exact moment before GBrain commits
to it.
The whole thing is deliberately weak by design, and that's the point. The
checker can only **watch**, never block. The interface returns nothing, so no
GBrain code can branch on a checker's verdict. A checker that crashes, hangs, or
times out can't break your import, your query, or your tool call — every hook
fails open. And the open-source distribution registers zero checkers, so the
seams are completely inert until you wire one in. No vendor code, no API URL, no
phone-home anywhere in the public tree.
This also closes a real gap. A prompt-injection payload that rides in through a
file import used to have no checkpoint at all, because the only places anyone
could hook were the LLM calls — and file content reaches retrieval without ever
touching one. `file_storage.markdown` and `file_storage.code` are real hooks at
that boundary now.
How to use it (you write a provider in your own package, ~80 lines):
```ts
import { registerGuardrailProvider } from 'gbrain/core/guardrails';
registerGuardrailProvider({
id: 'my-firewall',
async classify(input) {
// input.hook — which boundary ('file_storage.markdown', 'ai_gateway.chat', ...)
// input.content — the raw text to inspect
// input.metadata — context (slug, source_kind, tool_name, model, ...)
// Return value is ignored by GBrain. Log a redacted verdict from here.
await fetch(MY_API, { method: 'POST', body: JSON.stringify({ text: input.content }) });
},
});
```
Register it once at process init. Your provider owns its own timeout, secret
handling, redacted logging, and (if you don't want to block on it) async
fan-out. The full contract, the seam table, and the provider-authoring guide
are in `docs/guardrails.md`.
### Itemized changes
#### Added
- New module `src/core/guardrails.ts` — the vendor-neutral seam.
`runGuardrails({ hook, content, metadata })` returns `void` (observe-only by
construction). `registerGuardrailProvider` / `unregisterGuardrailProvider`
manage providers (idempotent by `id`). `hasGuardrails()` is a cheap hot-path
guard so hooks skip all work when no provider is registered. Providers are
snapshotted before iteration so a mid-flight register/unregister can't mutate
the running set, and every provider runs inside its own try/catch (one bad
provider can't stop a good one).
- Two `file_storage.*` seams in `src/core/import-file.ts`:
`file_storage.markdown` fires in `importFromContent` after `parseMarkdown` +
the size guard and before content-sanity, hashing, chunking, embedding, and DB
write; `file_storage.code` fires in `importCodeFile` after the code size guard
and before hashing, code-chunking, embedding, and DB write. Both cover every
natural ingest caller that routes through these functions (`gbrain import`,
sync, capture, `put_page`, subagent `brain_put_page`, trusted-workspace
writes, `ingest_capture`, inbox dispatch, reindex, code reindex, the public
import APIs).
- Three `ai_gateway.*` seams in `src/core/ai/gateway.ts`: `ai_gateway.chat`
classifies only the latest user message before provider inference;
`ai_gateway.expand` classifies the query before the expansion model call;
`ai_gateway.tool_input` classifies `{toolName, input}` before pending-persist
and before tool execution. Gateway hooks pass only the user/query/tool-input
payload — never system prompts, full chat history, tool output, LLM output,
embeddings, or multimodal/OCR/rerank payloads. A cycle-safe stringifier keeps
a weird tool payload from throwing inside the seam.
- `docs/guardrails.md` — the contract (five hard invariants: observe-only, fail
open, inline await, no verdict persistence, content boundaries), the seam
table, the provider-authoring guide, and a shadow-mode provider sketch.
### For contributors
- `test/guardrails.test.ts` (14 tests) pins the contract: `runGuardrails`
resolves `void`, a throwing or rejecting provider is swallowed (fail-open
isolation), a slow async provider is awaited before resolving (inline),
zero providers is a no-op, empty/blank content short-circuits, registration
is idempotent by `id`, and content + metadata pass through unmutated. The
existing import-file tests still pass — the ingest hot path is undisturbed.
## [0.41.34.0] - 2026-05-30
**Search now finds a page by its name, even when you only remember what you
+1 -1
View File
@@ -1 +1 @@
0.41.34.0
0.41.35.0
+102
View File
@@ -0,0 +1,102 @@
# Content Guardrail Seams
GBrain exposes **vendor-neutral guardrail seams** at the boundaries where
external content enters the retrieval layer and where queries/tool-inputs enter
the LLM gateway. A guardrail is any external classifier — a content firewall, a
prompt-injection detector, a PII scrubber — that wants to *observe* content at
those boundaries.
The OSS distribution ships **inert**: zero guardrails are registered by default,
and every seam is a no-op until an operator registers a provider.
## Design contract (hard invariants)
These hold for every seam and are enforced by `test/guardrails.test.ts`:
- **Observe-only.** `runGuardrails()` returns `void`. Callers never branch on a
provider verdict. A guardrail registered through this interface *cannot*
block, rewrite, drop, retry, or reorder GBrain behavior. Enforcement, if ever
added, will get its own explicitly-named seam and its own RFC — it will not
silently reuse this one.
- **Fail open.** Missing config, provider throw/reject, timeout, and network
error are all swallowed. A broken guardrail never breaks an ingest, a query,
or a tool call.
- **Inline await.** Hooks await the provider before proceeding, so the
classifier sees content at the exact pre-persist / pre-inference moment.
- **No verdict persistence.** GBrain writes no guardrail rows. Providers own
their own audit trail.
- **Content boundaries.** Hooks pass only the ingest/user-facing payload — the
markdown/code body, the last user message, the expansion query, the tool
input. They never pass system prompts, full chat history, tool *output*, LLM
output, embeddings, or multimodal/OCR/rerank payloads.
## The five seams
All seams call `runGuardrails({ hook, content, metadata })` from
`src/core/guardrails.ts`.
| `hook` | Location | Fires |
| --- | --- | --- |
| `file_storage.markdown` | `import-file.ts``importFromContent` | After `parseMarkdown` + size guard, **before** content-sanity, hashing, chunking, embedding, DB write |
| `file_storage.code` | `import-file.ts``importCodeFile` | After code size guard, **before** hashing, code-chunking, embedding, DB write |
| `ai_gateway.chat` | `ai/gateway.ts``chat` | On the **latest user message only**, before provider inference |
| `ai_gateway.expand` | `ai/gateway.ts``expand` | On the query, before the expansion model call |
| `ai_gateway.tool_input` | `ai/gateway.ts``toolLoop` | On `{toolName, input}`, before pending-persist and before tool execution |
The two `file_storage.*` hooks cover every natural ingest caller that routes
through `importFromContent` / `importCodeFile`: `gbrain import`, sync, capture,
`put_page`, subagent `brain_put_page`, trusted-workspace writes,
`ingest_capture`, inbox daemon dispatch, reindex, code reindex, and the public
import APIs.
## Writing a guardrail provider
```ts
import { registerGuardrailProvider, type GuardrailInput } from 'gbrain/core/guardrails';
registerGuardrailProvider({
id: 'my-firewall',
async classify(input: GuardrailInput) {
// input.hook — which boundary ('file_storage.markdown', etc.)
// input.content — the raw text to classify
// input.metadata — provider-opaque context (slug, source_kind, tool_name, model, ...)
//
// Do your own timeout/retry/logging here. The return value is IGNORED by
// GBrain — return a typed verdict only if your own audit code consumes it.
await fetch(MY_API, { method: 'POST', body: JSON.stringify({ text: input.content }) });
},
});
```
Register once at process init (e.g. from a plugin entry or an operator boot
hook). Registration is idempotent by `id`, so a re-init won't double-fire.
### Provider responsibilities
GBrain deliberately keeps the seam minimal. The provider owns:
- **Timeout discipline.** GBrain does not impose a timeout in `runGuardrails`
so you can tune per-deployment latency. Use an `AbortController`.
- **Secret handling.** Read API keys from env at call time. Never log the key.
- **Redacted logging.** Don't log raw classified content (it may itself be the
payload you're trying to protect). Log a hash + verdict, not the body.
- **Async fan-out.** If you don't want to block ingest on your classifier,
enqueue inside `classify` and return immediately. The seam awaits *your*
function; what it does is up to you.
## Example: shadow-mode firewall provider
A typical "shadow mode" provider (classify, log a redacted verdict, change
nothing) is ~80 lines and lives entirely in the provider's own package. See
the reference provider doc shipped to integration partners for a complete
`classify` implementation that:
1. resolves `<base>/classify` from an env URL,
2. posts `{ text, hook, metadata }` with an `x-api-key` header,
3. parses a `{ prediction, blocked, score, threshold }` response,
4. emits one redacted stderr line (`status=… prediction=… content_sha256=…`),
5. fails open on every error path.
Because the verdict is ignored by GBrain, "shadow mode" requires *no* special
GBrain flag — it is the only mode this interface supports. Enforcement would be
a separate, future, RFC-gated seam.
+1 -1
View File
@@ -141,5 +141,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.41.34.0"
"version": "0.41.35.0"
}
+113
View File
@@ -49,6 +49,7 @@ import { resolveModel, TIER_DEFAULTS } from '../model-config.ts';
import type { BrainEngine } from '../engine.ts';
import { dimsProviderOptions } from './dims.ts';
import { AIConfigError, AITransientError, normalizeAIError } from './errors.ts';
import { runGuardrails, hasGuardrails, type GuardrailHook } from '../guardrails.ts';
const MAX_CHARS = 8000;
// v0.36.0.0 (D3 + D4): ZeroEntropy zembed-1 at 1280d via Matryoshka is the
@@ -2019,6 +2020,13 @@ export async function expand(query: string): Promise<string[]> {
if (!query || !query.trim()) return [query];
if (!isAvailable('expansion')) return [query];
// Guardrail seam: classify the query before the expansion model call.
await classifyGatewayGuardrail({
hook: 'ai_gateway.expand',
content: query,
metadata: { query_chars: query.length },
});
try {
const { model, recipe, modelId } = await resolveExpansionProvider(getExpansionModel());
const result = await generateObject({
@@ -2281,9 +2289,101 @@ function mapStopReason(
* Crash-resumable replay is the caller's responsibility (subagent.ts persists
* blocks via the provider-neutral schema landing in commit 2a).
*/
/**
* Safely stringify an arbitrary tool-input value for guardrail classification.
* Cycle-safe; serializes functions/symbols/bigints to stable placeholders so a
* weird tool payload never throws inside the guardrail seam.
*/
function stringifyGuardrailValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
const seen = new WeakSet<object>();
try {
return JSON.stringify(value, (_key, nested) => {
if (typeof nested === 'bigint') return nested.toString();
if (typeof nested === 'function') return `[Function${nested.name ? `:${nested.name}` : ''}]`;
if (typeof nested === 'symbol') return nested.toString();
if (typeof nested === 'object' && nested !== null) {
if (seen.has(nested)) return '[Circular]';
seen.add(nested);
}
return nested;
});
} catch {
return String(value);
}
}
/** Flatten a chat message's content blocks into a single guardrail text. */
function chatContentToGuardrailText(content: ChatMessage['content']): string {
if (typeof content === 'string') return content;
return content
.map((block) => (block.type === 'text' ? block.text : stringifyGuardrailValue(block)))
.filter((part) => part.trim().length > 0)
.join('\n');
}
/**
* Find the latest user message for guardrail classification. Returns null when
* there's no non-empty trailing user message (nothing to classify).
*/
function lastUserMessageForGuardrail(
messages: ChatMessage[],
): { text: string; index: number } | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message || message.role !== 'user') continue;
const text = chatContentToGuardrailText(message.content);
if (text.trim().length === 0) return null;
return { text, index: i };
}
return null;
}
/**
* Gateway-side guardrail wrapper. Observe-only, fail-open, never throws into
* the gateway. No-op when no guardrail is registered. The guardrail boundary
* intentionally sees ONLY the user/query/tool-input text — never system
* prompts, assistant/tool messages, full history, tool output, or LLM output.
*/
async function classifyGatewayGuardrail(input: {
hook: GuardrailHook;
content: string;
metadata?: Record<string, unknown>;
}): Promise<void> {
if (!hasGuardrails()) return;
const content = input.content.trim();
if (!content) return;
try {
await runGuardrails({ hook: input.hook, content, metadata: input.metadata });
} catch {
// Fail open. A guardrail must never break inference or tool execution.
}
}
export async function chat(opts: ChatOpts): Promise<ChatResult> {
const tracker = __budgetStore.getStore() ?? null;
const modelStrEarly = opts.model ?? getChatModel();
// Guardrail seam: classify ONLY the latest user message before provider
// inference. Observe-only / fail-open; no-op without a registered guardrail.
if (hasGuardrails()) {
const lastUser = lastUserMessageForGuardrail(opts.messages);
if (lastUser) {
await classifyGatewayGuardrail({
hook: 'ai_gateway.chat',
content: lastUser.text,
metadata: {
model: modelStrEarly,
message_index: lastUser.index,
message_count: opts.messages.length,
},
});
}
}
const estimatedInputTokens = estimateChatInputTokens(opts);
const maxOutputTokens = opts.maxTokens ?? 4096;
@@ -2676,6 +2776,19 @@ export async function toolLoop(opts: ToolLoopOpts): Promise<ToolLoopResult> {
continue;
}
// Guardrail seam: classify tool input BEFORE pending-persist and BEFORE
// tool execution. Observe-only / fail-open. Sends only the tool name +
// input — never tool output, LLM output, or full conversation state.
await classifyGatewayGuardrail({
hook: 'ai_gateway.tool_input',
content: stringifyGuardrailValue({ toolName: call.toolName, input: call.input }),
metadata: {
turn_idx: turnIdx,
call_idx: callIdx,
tool_name: call.toolName,
},
});
// Step 2: persist pending row + claim gbrainToolUseId. The caller's
// callback handles uniqueness contention via ON CONFLICT DO NOTHING +
// re-read pattern (see persistToolExecPending in subagent.ts).
+137
View File
@@ -0,0 +1,137 @@
/**
* Vendor-neutral content guardrail seams.
*
* GBrain ingests content (markdown, code) into its retrieval layer and routes
* queries/tool-inputs through an LLM gateway. A guardrail is an external
* classifier — a content firewall, a PII scrubber, a prompt-injection detector —
* that wants to *observe* the content flowing across those boundaries.
*
* This module exposes the seams without binding GBrain to any specific vendor.
* Zero guardrails are registered by default; the OSS distribution ships inert.
* Operators (or vendor plugins) register a {@link GuardrailProvider} via
* {@link registerGuardrailProvider}, and the five hook points below await it
* inline.
*
* ## Hard invariants (do not weaken these without an RFC)
*
* - **Observe-only.** `runGuardrails` returns `void`. Callers MUST NOT branch
* on any provider verdict. A guardrail cannot block, rewrite, drop, retry, or
* reorder GBrain behavior through this interface. Enforcement, if ever added,
* gets its own explicitly-named seam and its own RFC.
* - **Fail open.** Missing config, provider throw, timeout, network error, and
* malformed responses are all swallowed. A broken guardrail never breaks an
* ingest, a query, or a tool call.
* - **Inline await, no enqueue.** These hooks await the provider before
* proceeding so the classifier sees content at the exact pre-persist /
* pre-inference moment. Providers that want async fan-out own their own queue.
* - **No verdict persistence.** GBrain does not write guardrail results to the
* DB. Providers own their own audit trail.
* - **Content boundaries.** Hooks pass the user/ingest-facing payload only:
* the markdown/code body, the last user message, the expansion query, the
* tool input. They never pass system prompts, full chat history, tool
* OUTPUT, LLM output, embeddings, or multimodal/OCR/rerank payloads.
*
* @module guardrails
*/
/**
* The boundary at which a guardrail is being consulted. Stable string union so
* providers can route/score per-surface without parsing free text.
*/
export type GuardrailHook =
/** Markdown/text body before chunking, embedding, and page persistence. */
| 'file_storage.markdown'
/** Code body before code-chunking, embedding, and page persistence. */
| 'file_storage.code'
/** Latest user message before LLM inference (chat). */
| 'ai_gateway.chat'
/** Search-expansion query before the expansion model call. */
| 'ai_gateway.expand'
/** Tool input before pending-persist and before tool execution. */
| 'ai_gateway.tool_input';
/**
* One guardrail invocation. `content` is the raw text the boundary handles;
* `metadata` is provider-opaque, JSON-compatible context (slug, source kind,
* tool name, model id, etc.). Neither field is mutated by GBrain.
*/
export interface GuardrailInput {
hook: GuardrailHook;
content: string;
metadata?: Record<string, unknown>;
}
/**
* A registered guardrail backend. `classify` is awaited inline. Its return
* value is intentionally `unknown` and intentionally ignored by GBrain — the
* type exists only so providers can return a typed verdict to *their own*
* logging/audit code. GBrain never reads it.
*/
export interface GuardrailProvider {
/** Stable id for logs and dedupe (e.g. `"silmaril"`). */
readonly id: string;
classify(input: GuardrailInput): Promise<unknown> | unknown;
}
const providers = new Map<string, GuardrailProvider>();
/**
* Register (or replace, by id) a guardrail provider. Idempotent per id so a
* plugin re-init doesn't double-fire. No-op safe to call before any ingest.
*/
export function registerGuardrailProvider(provider: GuardrailProvider): void {
if (!provider || typeof provider.classify !== 'function' || !provider.id) return;
providers.set(provider.id, provider);
}
/** Remove a previously-registered provider. Returns true if one was removed. */
export function unregisterGuardrailProvider(id: string): boolean {
return providers.delete(id);
}
/** Test/whole-reset helper. Clears all registered providers. */
export function __resetGuardrailProvidersForTests(): void {
providers.clear();
}
/** Whether any guardrail is registered. Lets hot paths skip work cheaply. */
export function hasGuardrails(): boolean {
return providers.size > 0;
}
/**
* Consult every registered guardrail for this boundary. Returns `void` — the
* result is never surfaced to the caller, by design (observe-only invariant).
*
* Fail-open: a provider that throws or rejects is isolated; its failure is
* swallowed so the ingest/inference/tool path proceeds unchanged. Empty/blank
* content short-circuits before any provider runs.
*
* Inline await: when guardrails are registered, the caller awaits this. The
* cost is bounded by each provider's own timeout discipline; GBrain does not
* impose one here so providers can tune per-deployment latency budgets.
*/
export async function runGuardrails(input: GuardrailInput): Promise<void> {
if (providers.size === 0) return;
const content = typeof input.content === 'string' ? input.content : '';
if (!content.trim()) return;
// Snapshot so a provider registering/unregistering mid-flight can't mutate
// the iteration set.
const snapshot = Array.from(providers.values());
await Promise.all(
snapshot.map(async (provider) => {
try {
await provider.classify({
hook: input.hook,
content,
metadata: input.metadata,
});
} catch {
// Fail open. A guardrail provider MUST NOT be able to break GBrain.
// Provider-side logging is the provider's responsibility; GBrain does
// not log raw content here (could itself leak the classified payload).
}
}),
);
}
+37
View File
@@ -31,6 +31,7 @@ import { loadSearchModeConfig, resolveSearchMode } from './search/mode.ts';
import { normalizeAliasList } from './search/alias-normalize.ts';
import { isUndefinedTableError, warnOncePerProcess } from './utils.ts';
import { computeCorpusGeneration } from './contextual-retrieval-service.ts';
import { runGuardrails } from './guardrails.ts';
/**
* v0.20.0 Cathedral II Layer 8 D2 — markdown fence extraction helper.
@@ -275,6 +276,26 @@ export async function importFromContent(
const parsed = parseMarkdown(content, slug + '.md', { activePack: opts.activePack });
// Vendor-neutral guardrail seam (observe-only, fail-open). Runs AFTER
// parseMarkdown and the size guard, BEFORE content-sanity, hash compute,
// chunking, embedding, and DB write — so a registered guardrail sees the
// full markdown payload at the exact pre-persist moment. The returned
// verdict is intentionally ignored: this seam cannot block or mutate the
// ingest. No-op when zero guardrails are registered (OSS default).
await runGuardrails({
hook: 'file_storage.markdown',
content,
metadata: {
slug,
source_id: sourceId ?? 'default',
source_path: opts.sourcePath ?? null,
source_kind: opts.source_kind ?? null,
source_uri: opts.source_uri ?? null,
ingested_via: opts.ingested_via ?? null,
content_type: 'markdown',
},
});
// v0.41 content-sanity gate. Runs AFTER parseMarkdown so the assessor
// sees the parsed body (compiled_truth + timeline), title, and
// frontmatter; runs BEFORE the hash compute so a soft-block that
@@ -898,6 +919,22 @@ export async function importCodeFile(
return { slug, status: 'skipped', chunks: 0, error: `Code file too large (${byteLength} bytes)` };
}
// Vendor-neutral guardrail seam (observe-only, fail-open). Runs AFTER the
// code size guard, BEFORE hash compute, code-chunking, embedding, and DB
// write. Verdict ignored by design; no-op when no guardrail is registered.
await runGuardrails({
hook: 'file_storage.code',
content,
metadata: {
slug,
source_id: sourceId ?? 'default',
source_path: relativePath,
source_kind: 'code',
content_type: 'code',
language: lang,
},
});
// Hash for idempotency. CHUNKER_VERSION is folded in so chunker shape
// changes across releases force clean re-chunks without sync --force.
const hash = createHash('sha256')
+168
View File
@@ -0,0 +1,168 @@
/**
* Vendor-neutral guardrail seam tests.
*
* Covers the contract every hook caller relies on:
* - observe-only (runGuardrails resolves void; never surfaces a verdict)
* - fail-open (provider throw/reject is swallowed)
* - inline await (provider is awaited before runGuardrails resolves)
* - inert by default (zero providers -> no-op, no provider calls)
* - empty/blank content short-circuits before any provider runs
* - register/unregister/reset semantics, idempotent by id
* - content + metadata are passed through unmutated
*
* Pure in-memory; no DB, no network.
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import {
registerGuardrailProvider,
unregisterGuardrailProvider,
__resetGuardrailProvidersForTests,
hasGuardrails,
runGuardrails,
type GuardrailInput,
type GuardrailProvider,
} from '../src/core/guardrails.ts';
function recordingProvider(id: string, calls: GuardrailInput[]): GuardrailProvider {
return {
id,
classify(input) {
calls.push(input);
},
};
}
describe('guardrails — inert by default', () => {
beforeEach(() => __resetGuardrailProvidersForTests());
test('no providers registered -> hasGuardrails() is false', () => {
expect(hasGuardrails()).toBe(false);
});
test('runGuardrails with no providers resolves void and does nothing', async () => {
const result = await runGuardrails({ hook: 'file_storage.markdown', content: 'hello' });
expect(result).toBeUndefined();
});
});
describe('guardrails — registration semantics', () => {
beforeEach(() => __resetGuardrailProvidersForTests());
test('register flips hasGuardrails() true; unregister flips it back', () => {
const calls: GuardrailInput[] = [];
registerGuardrailProvider(recordingProvider('p1', calls));
expect(hasGuardrails()).toBe(true);
expect(unregisterGuardrailProvider('p1')).toBe(true);
expect(hasGuardrails()).toBe(false);
});
test('registering same id twice does not double-fire', async () => {
const calls: GuardrailInput[] = [];
registerGuardrailProvider(recordingProvider('dup', calls));
registerGuardrailProvider(recordingProvider('dup', calls));
await runGuardrails({ hook: 'ai_gateway.chat', content: 'x' });
expect(calls.length).toBe(1);
});
test('malformed provider (no classify) is rejected silently', () => {
// @ts-expect-error intentionally malformed
registerGuardrailProvider({ id: 'bad' });
expect(hasGuardrails()).toBe(false);
});
test('provider without id is rejected', () => {
registerGuardrailProvider({ id: '', classify: () => {} });
expect(hasGuardrails()).toBe(false);
});
});
describe('guardrails — observe-only + fail-open', () => {
beforeEach(() => __resetGuardrailProvidersForTests());
test('a throwing provider never breaks runGuardrails (fail-open)', async () => {
registerGuardrailProvider({
id: 'boom',
classify() {
throw new Error('classifier exploded');
},
});
// Must resolve, not reject.
await expect(runGuardrails({ hook: 'file_storage.code', content: 'code' })).resolves.toBeUndefined();
});
test('a rejecting async provider never breaks runGuardrails', async () => {
registerGuardrailProvider({
id: 'reject',
async classify() {
throw new Error('async boom');
},
});
await expect(runGuardrails({ hook: 'ai_gateway.expand', content: 'q' })).resolves.toBeUndefined();
});
test('one bad provider does not stop a good provider (isolation)', async () => {
const calls: GuardrailInput[] = [];
registerGuardrailProvider({ id: 'bad', classify() { throw new Error('x'); } });
registerGuardrailProvider(recordingProvider('good', calls));
await runGuardrails({ hook: 'ai_gateway.tool_input', content: 'tool' });
expect(calls.length).toBe(1);
expect(calls[0]!.hook).toBe('ai_gateway.tool_input');
});
test('verdict returned by provider is ignored (no surface)', async () => {
registerGuardrailProvider({
id: 'verdict',
classify: () => ({ blocked: true, score: 0.99, prediction: 'MALICIOUS' }),
});
const result = await runGuardrails({ hook: 'file_storage.markdown', content: 'poison' });
// runGuardrails is void regardless of what the provider returns.
expect(result).toBeUndefined();
});
});
describe('guardrails — inline await', () => {
beforeEach(() => __resetGuardrailProvidersForTests());
test('runGuardrails awaits a slow async provider before resolving', async () => {
let settled = false;
registerGuardrailProvider({
id: 'slow',
async classify() {
await new Promise((r) => setTimeout(r, 25));
settled = true;
},
});
await runGuardrails({ hook: 'ai_gateway.chat', content: 'hi' });
// If runGuardrails returned before awaiting, settled would still be false.
expect(settled).toBe(true);
});
});
describe('guardrails — content guards', () => {
beforeEach(() => __resetGuardrailProvidersForTests());
test('empty content short-circuits before provider runs', async () => {
const calls: GuardrailInput[] = [];
registerGuardrailProvider(recordingProvider('p', calls));
await runGuardrails({ hook: 'file_storage.markdown', content: '' });
expect(calls.length).toBe(0);
});
test('whitespace-only content short-circuits', async () => {
const calls: GuardrailInput[] = [];
registerGuardrailProvider(recordingProvider('p', calls));
await runGuardrails({ hook: 'file_storage.markdown', content: ' \n\t ' });
expect(calls.length).toBe(0);
});
test('content and metadata pass through unmutated', async () => {
const calls: GuardrailInput[] = [];
registerGuardrailProvider(recordingProvider('p', calls));
const meta = { slug: 'people/jane', source_kind: 'webpage', nested: { a: 1 } };
await runGuardrails({ hook: 'file_storage.markdown', content: 'body text', metadata: meta });
expect(calls.length).toBe(1);
expect(calls[0]!.content).toBe('body text');
expect(calls[0]!.metadata).toEqual(meta);
});
});