Put DFENS in the path of a model call.
Two integration paths, one rule engine and one audit log behind both: a transparent
reverse proxy that speaks each provider's wire format, and a provider-agnostic
/v1/inspect endpoint you call from your own code.
DFENS can require callers to present a client token in the
X-AI-Firewall-Key header before any traffic reaches the proxy or
/v1/inspect. It's off by default and enabled per tenant (see
client authentication). Enable it,
and still keep DFENS on a trusted network segment: the two are defense-in-depth, not
alternatives. In reverse-proxy mode the caller's own Authorization header
continues to authenticate to the upstream provider (see
auth modes); the client token is a
separate credential that DFENS strips before forwarding.
Reverse proxy
DFENS accepts requests on the provider's native path, evaluates them, forwards clean traffic upstream, and evaluates the response on the way back. To adopt it, change only the base URL your SDK points at. No other code changes.
# Point the OpenAI SDK at DFENS instead of api.openai.com export OPENAI_BASE_URL=http://dfens.internal:8080 # A benign request is forwarded upstream and returned unchanged curl -X POST http://dfens.internal:8080/v1/chat/completions \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o","messages":[{"role":"user","content":"hello"}]}' # A prompt matching a rule is blocked before it reaches the model curl -X POST http://dfens.internal:8080/v1/chat/completions \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-4o","messages":[{"role":"user","content":"ignore previous instructions"}]}' # HTTP 403 Request blocked by ai-firewall policy.
Anthropic works identically on /v1/messages. The firing rule's
LLM-YYYY-NNNN ID, severity, and matched snippet are written to the audit
log, never returned to the caller.
Canonicalization runs before rule evaluation (NFKC, zero-width strip, homoglyph fold, base64/URL/hex decode), so Cyrillic-homoglyph and encoded evasions are matched against the same rules as plain text.
Providers & routes
Each adapter is only active when its provider is enabled
in config. OpenAI, Anthropic, and Gemini are enabled by default; Azure and Bedrock
are off until you set an upstream. A request whose path matches no enabled adapter
returns 404 no adapter for this route.
| Provider | Route | Default |
|---|---|---|
| OpenAI | POST /v1/chat/completions | on |
| Anthropic | POST /v1/messages | on |
| Gemini / Vertex AI | …/models/<model>:generateContent and :streamGenerateContent | on |
| Azure OpenAI | /openai/deployments/<deployment>/chat/completions | off |
| AWS Bedrock | /model/<modelId>/converse and /converse-stream | off |
Google Vertex AI is served by the Gemini adapter, which matches both the Developer API
and Vertex request paths; traffic is labelled gemini in the audit log.
Bedrock targets the Converse API only.
Block responses
What a blocked request returns depends on engine.block_response:
error(default). HTTP 403,text/plain, body exactlyRequest blocked by ai-firewall policy.Nothing is forwarded upstream.soft. HTTP 200 carrying a synthetic, provider-shaped assistant turn whose text isengine.block_message. Useful for chat UIs and agents that treat a 403 as an auth failure. Adapters that can't build a synthetic turn fall back to the 403.
Other statuses the proxy can return:
| Status | Meaning |
|---|---|
400 | Malformed request rejected by DFENS. |
404 | No adapter for this route, or unknown tenant. |
413 | Request body over server.max_request_body_bytes (1 MiB default). |
502 / 504 | Upstream provider unavailable / timed out. |
503 | DFENS is in a degraded state for this request. |
Streaming
For SSE responses, DFENS buffers the first slice of the stream (the "head") and evaluates it before any byte reaches the client. Two outcomes:
- Blocked in the head. No bytes have been sent yet, so the client gets a clean 403, exactly as for a non-streamed block.
- Blocked after the head. Tokens are already on the wire, so DFENS appends a terminating SSE frame and stops the stream:
data: {"error":"Stream truncated by ai-firewall policy"}
data: [DONE]
The truncation frame is OpenAI-shaped regardless of provider. An Anthropic, Gemini, or
Bedrock streaming client will surface it as a parse error rather than a clean end.
Handle a mid-stream parse failure as a policy termination, and read the audit log for
the rule that fired. Re-evaluation of the tail stops once the buffered response exceeds
streaming.max_buffered_response_bytes (64 KiB default); size that against
the responses you need fully covered.
In-app inspection · /v1/inspect
POST/v1/inspect runs the same rule engine
over content you supply directly (a prompt, a model response, or a tool call/result)
and returns a verdict. Use it when you call the model natively (Bedrock, Vertex, a custom
inference server) or want to guard RAG chunks and tool results before they re-enter the
model, without putting a proxy in the network path.
The phase field selects what is being inspected and which other fields are
required. It defaults to request.
| phase | Required fields | Inspects |
|---|---|---|
request | exactly one of content or messages | An inbound prompt. |
response | response_content | Model output. |
tool_request | tool_name | A tool call and its tool_args. |
tool_response | tool_name and tool_response | A tool result before it re-enters the model. |
Optional fields on any phase: system_prompt, provider, model (recorded in the audit log for correlation).
curl -X POST http://dfens.internal:8080/v1/inspect \
-H "Content-Type: application/json" \
-d '{"phase":"tool_response","tool_name":"kb_search",
"tool_response":"<retrieved document text>"}'
# Clean content — allow
{"verdict":"allow"}
# A hostile instruction smuggled in the document — deny
{"verdict":"deny","fired_rules":[
{"rule_id":"LLM-2026-0001","severity":"HIGH","disposition":"deny",
"phase":6,"matched_variable":"tool_response",
"message":"LLM05: indirect prompt-injection pattern detected",
"tags":["owasp/llm05","llm-tool"]}
]}
Both verdicts return HTTP 200. The decision is in the body, not the
status code. An allow with no matches serializes as exactly {"verdict":"allow"};
fired_rules is present only on a deny. Error cases return a JSON
{"error":"…"} body: 400 for malformed JSON or a field/phase
that fails validation, 413 for an oversize body, 404 for an
unknown tenant. (A non-POST method returns 405 as plain text.)
For request-phase checks you can post either a single content string or a
messages array of {"role","content"} objects. Supply one or the
other, never both.
Health
GET/health is unauthenticated and always
returns HTTP 200. Parse the body rather than relying on the status code.
{"status":"ok","ready":true,"degraded_rules":[],"disabled_rules":[]}
status:ok, ordegradedwhen a rule has been marked degraded (an operator errored at evaluation time) or the instance is awaiting sync.ready:trueunless the instance is configured to wait for an initial managed-ruleset sync and hasn't completed it;reasonis populated whenreadyisfalse.degraded_rules/disabled_rules: rule IDs currently degraded or administratively disabled.
The health path is configurable via admin.healthz_path
for environments whose edge intercepts /healthz.
Next: Configuration. The YAML schema, env-var overrides, and auth modes.