Surface 03 · CanaryShield
A three-layer firewall for your model
Shield is two front doors onto one pipeline. POST /canaryshield/inspect takes a string and returns a verdict; it has no opinion about where the string came from. The OpenAI-compatible proxy wraps the same pipeline around chat completions, so an app that already speaks OpenAI adopts Shield by changing a base URL. Both run the same three layers — deterministic rules, an LLM analyzer, and a sanitizer.
Reach for /inspect first. It works against any model, any provider, a self-hosted endpoint, a streaming response and text in any message role, because you choose what to send it. The proxy is the faster adoption path and stops an injection before your provider bills you for it, but it serves one route and reads one message role. Both boundaries are stated below rather than left for you to find.
The primitive: score one message
One POST, one string, one verdict. user_id is the UUID of the authenticated caller — a mismatch is a 403, not a silent rescope. Authenticate with your workspace key in X-API-Key, or with a Supabase session JWT in Authorization: Bearer; send both and they have to name the same account.
A block and a clean pass carry the same six keys, so you branch on blocked alone. On a block sanitized_message is null. On a clean pass it holds the text to send onward: layer 3 runs over everything that gets through and may have excised a span, so forward sanitized_message rather than the string you sent.
Two optional fields widen the judgement. system_prompt is handed to the analyzer, so an override attempt is scored against the instructions it is trying to override. history takes up to 20 earlier turns, oldest first, so an attack staged across several messages is judged against the conversation instead of the last line of it. Every text field is capped at 100,000 characters.
/inspect is not an observe-only mode. A verdict over the block threshold is written to your Shield log and pushed to your alert channels exactly like a proxy block — what differs is that nothing is forwarded to a model and nothing is rewritten on your behalf. The enforcement is your if.
The drop-in proxy, and where it stops
The proxy is the fastest way to put Shield in front of an app that already speaks OpenAI: change the base URL, keep the rest. Your provider key stays in api_key and is forwarded upstream unchanged; CanaryVaults identity travels only in the X-API-Key header.
The proxy serves exactly one route: POST /v1/chat/completions, forwarded to /v1/chat/completions on the upstream. Nothing else is registered under the Shield base URL, so client.models.list(), client.embeddings.create(), the Responses API, Assistants, files and audio all 404 against it. The one-line pitch — change the base URL, keep everything else — holds for chat completions and for nothing else, so keep a second, unproxied client for the rest.
The proxy inspects the user turns of the conversation: every message with role: "user", not only the newest, with the earlier ones passed to the analyzer as history. Content arriving in any other role is forwarded unread. If you are building agents this is the fact to plan around — when your agent pastes a scraped page into a role: "tool" message, the proxy hands it to your model without looking at it, and a tool result is where an injection actually arrives in 2026. Assistant turns and system prompts pass through uninspected too. Score them yourself with /inspect.
The proxy also does not pass your system prompt to the analyzer. Layer 2 sees the user turns and nothing else, so it scores ignore your instructions without knowing what those instructions were. /inspect accepts a system_prompt and forwards it, which is the better-informed of the two verdicts.
The default upstream is api.openai.com — send X-Upstream-Base-URL: https://api.groq.com to proxy Groq instead. The header is validated against a closed allowlist and https is mandatory. The permitted hosts are api.openai.com, api.groq.com, openrouter.ai, api.together.xyz, api.mistral.ai, api.deepseek.com and api.x.ai, plus anything the operator adds through SHIELD_PROXY_EXTRA_UPSTREAMS. Any other value is a 400 — a self-hosted vLLM, a local Ollama and Anthropic direct are all refused. Streaming is not supported yet — set stream=False. /inspect has none of these constraints, because it never contacts a provider.
Observe first, then enforce
The proxy runs in two modes, chosen by base URL. The enforcing proxy answers a detection with an OpenAI-shaped error naming the attack class and confidence, and the request never reaches your provider. The flag-only proxy runs the same three layers and writes the same evidence, but forwards the request anyway and reports the verdict in response headers — so you can measure Shield against your own traffic before it is able to turn anything away.
Whether a detection blocks is decided on confidence. Layer 1, the deterministic rules, blocks at a confidence of 90 or above; layer 2, the LLM analyzer, blocks at 75 or above. The keyword heuristic caps its own score at 89, below the layer-1 threshold, so a keyword match on its own can never turn a request away.
A flagged response carries x-canaryshield: flagged, x-canaryshield-would-block: true, and the attack class, confidence and deciding layer. Clean traffic is marked x-canaryshield: inspected in both modes.
The mode lives in your base URL rather than in a request header on purpose. A header is settable by whoever sends you the request, so on a service that proxies end-user traffic it would be an off switch for your own firewall.
Flag-only detections are marked as such in the evidence log and in the alert, so an observation is never counted as a block that happened. Both modes inherit the proxy boundaries above, so what flag mode measures is the proxy against your traffic rather than Shield against your traffic: whatever reaches your model in a tool result or an assistant turn is missing from the sample. Send that through POST /canaryshield/inspect to see it.
What gets recorded
Every detection — blocked or flagged — is written to your Shield log with the original message, the attack class, the confidence score and the explanation. The detection is then pushed to your alert channels exactly like any other detection: built-in Telegram, built-in email, and every enabled Slack, Discord and signed-webhook channel on the account, through the same shared dispatcher CanaryHoneypot and CanaryAgent use. Each delivery is isolated, so one failing channel never blocks the rest. Individual paths still skip silently — Telegram when no bot token is configured or no chat is linked, email when no address is on file, and both when alerts are paused — so a detection whose every channel skips or fails is still recorded with no notification sent anywhere, and the server logs that the alert reached nobody.
Which layer made the call is reported differently by each of the three surfaces. POST /canaryshield/inspect returns decided_by as a JSON field. Flag mode reports it as the HTTP response header x-canaryshield-decided-by. The enforcing proxy does not report it at all: a blocked response carries no decided_by and no x-canaryshield-* headers, only error.canaryshield holding blocked, attack_type, confidence and explanation.
Shield records are ordinary rows, not hash-sealed evidence. If you need a tamper-evident chain over agent actions, that is CanaryAudit — Shield logs tell you what was attempted, CanaryAudit proves a record has not changed since it was written.
Quota, rate limits and analyzer outages
Both proxy routes declare their own limit of 120 requests per minute per IP. /canaryshield/inspect is rate limited per minute as well, keyed to your account rather than to your IP — the better key for server-side traffic. Unlike the proxy routes the figure is per plan: 10 a minute on Free, 30 on Shield, 120 on Pro and 300 on Enterprise, so against the proxy's flat 120 it is the tighter ceiling on Free and Shield, exactly level on Pro, and the looser one on Enterprise. A 429 is the signal to back off and retry; it is not a quota refusal and costs you nothing. The allowances to size an agent loop against are still the per-day and per-month ones below, which are what actually ration.
Quota is the same unit either way. Every proxied completion and every /inspect call spends one shield_inspections_per_month — 10 on Free, 500 on Shield, 3,000 on Pro and 10,000 on Enterprise. Once the allowance is used up the call answers 402 instead of a verdict, so size the quota against all of your traffic, not just the attacks in it. Inspecting tool results as well as user turns multiplies the count by the hops in your agent loop.
A shorter ceiling sits underneath the monthly one: a per-account daily allowance of 5 inspections on Free, 40 on Shield, 150 on Pro and 400 on Enterprise, spent by the proxy and /inspect alike. This is the ceiling most callers meet first. On Free it sits below the per-minute limit, so a Free account is refused for the day at five inspections without ever reaching its own burst ceiling; on every paid tier a flat-out minute or two spends the whole day. You can hit it while the monthly quota still has plenty left — 150 a day on Pro reaches the 3,000 monthly figure only across 20 busy days — so size a burst against this number rather than against the monthly one. The daily 402 names the same metric, with resets_at set to the next UTC day rather than the first of next month; and unlike every other meter here it fails closed, so if your usage cannot be checked the call answers 503 with daily_quota_unavailable instead of passing unchecked.
Shield fails closed on both paths. If the analyzer is unavailable the proxy returns 503 and the request is not forwarded upstream — your model never sees an uninspected message — and /inspect returns 503 with nothing scored. In both cases the inspection unit the call would have spent is refunded. The proxy sends its 503 in the OpenAI error shape with code canaryshield_unavailable so your SDK retries cleanly; /inspect answers with a plain detail string. Treat either as not inspected, never as clean.