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