mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
* 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:
co-authored by
garrytan-agents
Claude Opus 4.8
parent
d6db3f0ce3
commit
0b2a26a31d
@@ -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
|
||||
|
||||
@@ -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
@@ -141,5 +141,5 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.41.34.0"
|
||||
"version": "0.41.35.0"
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user