diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d9925aec..857b8e63f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index bbd1e5c01..e8eb02247 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.34.0 \ No newline at end of file +0.41.35.0 \ No newline at end of file diff --git a/docs/guardrails.md b/docs/guardrails.md new file mode 100644 index 000000000..7e5b5a668 --- /dev/null +++ b/docs/guardrails.md @@ -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 `/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. diff --git a/package.json b/package.json index b0f931852..4b7aee086 100644 --- a/package.json +++ b/package.json @@ -141,5 +141,5 @@ "bun": ">=1.3.10" }, "license": "MIT", - "version": "0.41.34.0" + "version": "0.41.35.0" } diff --git a/src/core/ai/gateway.ts b/src/core/ai/gateway.ts index 7225a05ce..1527b3356 100644 --- a/src/core/ai/gateway.ts +++ b/src/core/ai/gateway.ts @@ -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 { 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(); + 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; +}): Promise { + 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 { 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 { 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). diff --git a/src/core/guardrails.ts b/src/core/guardrails.ts new file mode 100644 index 000000000..48b95e3e4 --- /dev/null +++ b/src/core/guardrails.ts @@ -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; +} + +/** + * 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; +} + +const providers = new Map(); + +/** + * 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 { + 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). + } + }), + ); +} diff --git a/src/core/import-file.ts b/src/core/import-file.ts index 82a0d5c00..e07996c93 100644 --- a/src/core/import-file.ts +++ b/src/core/import-file.ts @@ -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') diff --git a/test/guardrails.test.ts b/test/guardrails.test.ts new file mode 100644 index 000000000..5800ad0f9 --- /dev/null +++ b/test/guardrails.test.ts @@ -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); + }); +});