Gateway Proxy
Supported modes: Hosted Hybrid Available in: Free Solo Teams
Control Zero Gateway is a transparent proxy that sits between your AI agents and LLM providers. It intercepts every request, evaluates tool calls against your policies, and blocks unauthorized actions, all without changing your agent's code.
The gateway governs traffic at the LLM API boundary. Use the SDK for finer in-process control in a new application, or coding hooks to govern developer-tool actions inside Claude Code, Cursor, and Codex CLI.
How It Works
The gateway operates in two phases:
- Pre-flight (request guard): Before forwarding to the LLM, the gateway checks model blocking rules, estimates cost against budget caps, and scans for PII in prompts.
- Response interception: After the LLM responds, every
tool_use(Anthropic) orfunction_call(OpenAI) block is evaluated against your policies. Denied tool calls are replaced inline with a policy denial message. Both streaming and non-streaming responses are supported.
Quick Start
Change your LLM provider base URL to point to the Control Zero gateway:
Anthropic (Claude)
# Before
ANTHROPIC_BASE_URL=https://api.anthropic.com
# After
ANTHROPIC_BASE_URL=https://gateway.controlzero.ai
Add the headers:
X-ControlZero-API-Key: cz_live_xxx
X-ControlZero-Agent-ID: my-first-agent
X-ControlZero-API-Key(required) is your project key from the dashboard.X-ControlZero-Agent-ID(optional) labels the caller for audit attribution. Defaults to<provider>-direct(e.g.anthropic-direct) if omitted.
OpenAI
# Before
OPENAI_BASE_URL=https://api.openai.com
# After
OPENAI_BASE_URL=https://gateway.controlzero.ai/v1
Add the same Control Zero headers as above.
That is it. No SDK installation, no code changes. Your existing agent code keeps working. The gateway enforces your policies transparently.
What the agent receives when a tool call is blocked
When the gateway blocks a request, the agent receives an HTTP 403 response with a JSON body:
{
"error": "policy_denied",
"reason": "<human-readable reason from the matching policy>",
"policy_id": "<id of the policy that matched>"
}
Handle this in your agent code the same way you'd handle an API error from the LLM provider.
Features
Pre-flight Request Guard
Before forwarding to the LLM provider, the gateway runs these checks:
- Model blocking: Deny requests to unauthorized models (e.g., block agents from using expensive models).
- Cost estimation: Reject if estimated token cost exceeds your budget cap.
- PII detection, masking, and blocking: Detect, mask, or block PII in prompts. Under
maskthe gateway redacts each match and forwards the request with the redacted body -- it does not deny. Masking here is fail-closed: if redaction raises, or if it runs and leaves the body unchanged while PII was detected, the request is denied withDLP_BLOCKEDrather than forwarded. Two things can selectmaskon this path:CZ_GATEWAY_PII_ACTION=mask, or a policy bundle whosepii_profile.actionismask-- the bundle route means request masking can be turned on from the control plane without a gateway environment change.
If the policy engine is unavailable and fail_closed is enabled (the default), all requests are blocked. No silent failures.
Response-Side DLP
Response-side DLP scans what the model sends back — model output containing credit card numbers, national ID numbers, health records, or API keys — before that output reaches the caller. It runs on both response shapes: JSON bodies on the non-streaming path, and streamed SSE responses through a hold-back guard that withholds bytes across a chunk boundary until it has scanned across it, so a secret split over two chunks is not delivered ahead of detection.
CZ_GATEWAY_RESPONSE_DLP_ENABLED defaults to false, and none of the compose files shipped in this repository set it. Until you set it, model responses are not scanned — and CZ_GATEWAY_PII_ACTION alone does not turn it on. PII_ACTION selects what happens to a finding; it does not decide whether the response is looked at.
Request-side PII scanning is separate and is not gated by this flag.
CZ_GATEWAY_RESPONSE_DLP_ENABLED=true # the master switch
CZ_GATEWAY_PII_ACTION=block # what to do with a finding
While the switch is off, no finding can be produced for a response, so treat an empty finding list on a response as "not looked at" rather than "clean". The proxy marks each response response_dlp_scanned=false internally, but that marker is not currently persisted to the audit trail your dashboard queries -- the gateway's audit path forwards only a fixed set of fields to the backend, and this is not among them. Until it is, the switch itself is the only reliable answer to "was this response scanned".
Once enabled, CZ_GATEWAY_PII_ACTION selects what happens to a finding:
| Mode | Behavior |
|---|---|
detect | Log the finding in the audit trail but return the response unmodified. |
mask | Replace each match in the response body with a placeholder and deliver the redacted response. |
block | Withhold the matched content and everything after it, and return a policy denial. On a non-streaming response nothing is delivered at all. On a streaming response the clean prefix already sent cannot be recalled, but the hold-back buffer means the matched value itself is never delivered. |
If the response scanner itself raises, the gateway fails closed: it withholds the unscanned response and returns the standard governance error rather than delivering it. CZ_GATEWAY_RESPONSE_DLP_FAIL_OPEN=true is an explicit opt-out of that posture.
One gap to know about: on the non-streaming path, a 2xx body that is not valid JSON cannot be parsed, so it is delivered unchanged and unscanned — the fail-closed posture covers scanner errors, not unparseable bodies. The proxy marks that response unscanned internally, but as above that marker does not reach the audit trail today, so this gap is not visible to a dashboard query.
The pattern library ships 12 locale and category sets. CZ_GATEWAY_DLP_LOCALES defaults to default alone (28 patterns); adding eu, ja, ko, medical and secrets brings it to 59, and all 12 sets together resolve to 64 unique patterns. See DLP coverage and locales for the full list and how to enable a set.
Rate Limiting
The gateway enforces configurable rate limits at three scopes:
| Scope | Environment Variable | Default | Description |
|---|---|---|---|
| Per-user | CZ_GATEWAY_RATE_LIMIT_PER_USER | 100 | Maximum requests per minute per user identity. |
| Per-org | CZ_GATEWAY_RATE_LIMIT_PER_ORG | 1000 | Maximum requests per minute per organization. |
| Per-provider | CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER | 500 | Maximum requests per minute per upstream provider (fallback when no per-provider override is set). |
Per-provider overrides are supported via CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER_<NAME>, for example CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER_OPENAI=2000 or CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER_ANTHROPIC=1500. When a request's provider has a specific override set, that value is used; otherwise the fallback CZ_GATEWAY_RATE_LIMIT_PER_PROVIDER applies.
Rate limit state is stored in an in-memory cache (CZ_GATEWAY_REDIS_URL, default redis://localhost:6379 — Redis-compatible URL scheme) and shared across gateway instances using a 60-second sliding window. When the cache is unreachable, rate limiting fails open and requests are allowed through with a warning logged.
When a limit is exceeded, the gateway returns HTTP 429 with a Retry-After header along with X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
Correlation IDs
Every request processed by the gateway is assigned a correlation ID. If the caller includes an X-Request-ID header, the gateway preserves it. Otherwise, a new unique ID is generated.
The correlation ID is:
- Returned in the
X-Request-IDresponse header. - Included in every audit log entry for the request.
- Propagated to upstream LLM providers where supported.
Use correlation IDs to trace a single request across your agent, the gateway, and the LLM provider.
Prometheus Metrics
The gateway exposes a /metrics endpoint in Prometheus exposition format. Scrape it with any Prometheus-compatible collector.
Available metric families:
| Metric | Type | Description |
|---|---|---|
cz_gateway_requests_total | Counter | Total requests by provider, model, and status code. |
cz_gateway_request_duration_seconds | Histogram | Request latency distribution. |
cz_gateway_policy_evaluations_total | Counter | Policy evaluations by decision (allow/deny). |
cz_gateway_pii_detections_total | Counter | PII detections by type and direction (req/resp). |
cz_gateway_rate_limit_hits_total | Counter | Rate limit rejections by scope. |
cz_gateway_upstream_errors_total | Counter | Upstream provider errors by provider and status. |
cz_gateway_active_connections | Gauge | Current active connections. |
cz_gateway_identity_conflicts_total | Counter | Identity conflicts by field and resolution mode. See Which identity wins. |
/metrics is served by the gateway application itself, on the same port that
serves proxy traffic — port 8000 inside the container, for example
http://localhost:8000/metrics. There is no separate metrics port and no
switch to turn the endpoint off; it is registered unconditionally at startup.
Set CZ_GATEWAY_METRICS_ORG_LABEL=true to add an org_id label to the
auto-instrumented request metrics.
Scrape the /metrics endpoint with Prometheus and build Grafana dashboards from the counters and histograms above.
Tool Call Interception
After the LLM responds:
- Every
tool_useblock (Anthropic) orfunction_call(OpenAI) is evaluated against your policies. - Denied tool calls are replaced inline with a policy denial message.
- Each decision is logged separately for auditing.
- Both streaming and non-streaming responses are supported.
Supported Providers
| Provider | Gateway Path | Protocol |
|---|---|---|
| Anthropic (Claude) | /v1/messages | Anthropic Messages API |
| OpenAI (GPT) | /v1/chat/completions | OpenAI Chat Completions |
| Google AI (Gemini) | /google/v1beta/models/{model}:generateContent | Native Gemini (also :streamGenerateContent) |
| Ollama | /ollama/v1/chat/completions | OpenAI-compatible |
| DeepSeek | /deepseek/chat/completions | OpenAI-compatible |
| MoonshotAI | /moonshot/v1/chat/completions | OpenAI-compatible |
| HuggingFace TGI | /huggingface/v1/chat/completions | OpenAI-compatible (no tool interception) |
| Mistral | /mistral/v1/chat/completions | OpenAI-compatible |
| Cohere | /cohere/v1/chat/completions | OpenAI-compatible |
| AWS Bedrock | /bedrock/v1/chat/completions | OpenAI-compatible |
| Google Vertex AI | /vertex/v1/chat/completions | OpenAI-compatible |
| Azure OpenAI | /azure-openai/v1/chat/completions | OpenAI-compatible |
| Snowflake Cortex | /snowflake/v1/external-function | Snowflake external function |
Google AI, Ollama, DeepSeek, MoonshotAI, HuggingFace, Mistral, and Cohere are disabled by default. Enable them with environment variables:
CZ_GATEWAY_GOOGLE_ENABLED=true
CZ_GATEWAY_OLLAMA_ENABLED=true
CZ_GATEWAY_DEEPSEEK_ENABLED=true
CZ_GATEWAY_MOONSHOT_ENABLED=true
CZ_GATEWAY_HUGGINGFACE_ENABLED=true
CZ_GATEWAY_MISTRAL_ENABLED=true
CZ_GATEWAY_COHERE_ENABLED=true
Identity and Context Headers
| Header | Required | Description |
|---|---|---|
X-ControlZero-Agent-ID | Yes | Identifies the agent making the call |
X-ControlZero-API-Key | Yes | Control Zero API key (cz_live_ or cz_test_) |
X-ControlZero-Identity-Token | Optional | JWT from your identity provider. The gateway verifies the signature against your IdP's JWKS before using any claim. See Which identity wins. |
X-ControlZero-User-ID | Optional | User identifier for policy scoping. Caller-asserted and unsigned. |
X-ControlZero-User-Group | Optional | User group for RBAC policy evaluation. Caller-asserted and unsigned. |
Which identity wins
Policy rules match on the resolved user_id and user_group, so when two
sources supply the same field it matters which one the gateway believes.
The two sources are not equivalent:
X-ControlZero-Identity-Tokenis verified. The gateway fetches your IdP's JWKS, verifies the token signature against the matching signing key, and pins the accepted algorithms toRS256andES256. The issuer and audience are checked against your configured IdP. A token that fails any of those checks is rejected and contributes no claims at all. This verification runs on every provider proxy route that resolves identity: Anthropic, OpenAI, Google, Azure OpenAI, Bedrock, Vertex AI, and the shared OpenAI-compatible route that serves Ollama, DeepSeek, MoonshotAI, HuggingFace, Mistral, and Cohere. It is a signature check rooted in your IdP, not a convenience header.X-ControlZero-User-IDandX-ControlZero-User-Groupare not verified. Whatever the caller types is what the gateway receives. There is no signature to check.
Resolution is controlled by the CZ_GATEWAY_IDENTITY_PRECEDENCE environment
variable on the gateway. It is a deployment setting, not a dashboard setting:
there is no control-plane surface that changes it. On Hosted, Control Zero
operates the gateway, so this is not a value you set — the checks below are
for a gateway you run yourself, in Hybrid or self-managed mode.
| Value | What the gateway does |
|---|---|
enforce | The verified claim wins. The unsigned header is discarded for any field the token asserts. |
observe | The unsigned header wins, which is how the gateway behaved before this change. |
Conflicts are detected identically in both modes. Detection is a comparison of the distinct values each source supplied, run after resolution and independent of it, so switching modes changes which identity policy receives, never whether the disagreement is noticed.
The gateway refuses to start on an unrecognised value rather than run in an identity posture that cannot be named. A typo does not silently fall back to either mode.
The built-in default and the shipped compose value are not the same today
This is the part to read carefully before assuming which mode you are running.
- The built-in default in the gateway code is
enforce. That is the value used whenCZ_GATEWAY_IDENTITY_PRECEDENCEis unset or empty. - Both compose files we ship set
CZ_GATEWAY_IDENTITY_PRECEDENCE=observeexplicitly — the production compose and the on-prem compose. An explicit value always beats the built-in default.
So a deployment brought up from our compose files runs observe, and reaches
the built-in enforce only if you remove or override that line. Do not infer
your mode from the default; read it off the running gateway.
Check the boot log first. The gateway announces the posture before it accepts traffic, and prints the effective mode, the raw env value, and the built-in default on one line:
docker logs <gateway-container> 2>&1 | grep 'identity precedence mode='
A gateway started from our compose prints:
identity precedence mode=observe (env CZ_GATEWAY_IDENTITY_PRECEDENCE='observe', default=enforce)
An empty env ...='' in that line means nothing was configured and the
built-in default applied. If the grep prints nothing at all, the log may have
rotated past startup — restart the container or fall back to reading the
value the live process holds:
docker exec <gateway-container> printenv CZ_GATEWAY_IDENTITY_PRECEDENCE \
|| echo "unset -- the built-in default (enforce) applies"
printenv exits non-zero and prints nothing when the variable is unset, so
the || branch is what keeps "unset" distinguishable from "I could not read
it". A .env or compose file on disk is not an answer to this question: only
the running process is.