This page proves what the gateway enforces in the AI traffic path, not what a feature list claims: every row below points at a named test or a signal you can go check yourself, and every named limit is paired with the engineering reason that behavior is honest and what compensates for it. Three phrases carry the whole vocabulary. Enforced means the control runs in the traffic path and a test or a signal proves it. Enforced, with named limits means the same, plus a stated boundary and why crossing it costs more than it buys. Out of gateway scope means the risk lives in the application or the provider, and a proxy sitting on the request path cannot own it.
The eight controls no published list covers
An OWASP list describes risks to the traffic passing through a gateway. It does not describe the gateway itself, and a proxy sitting on every request has failure modes that an application-security taxonomy was never written to catch. This is the set we hold ourselves to, written so an operator running a different gateway can lift the checklist and grade their own deployment against it.
1. Credential custody
Callers authenticate to the gateway; the gateway holds the provider credentials, and the two never trade places. Inbound keys are stored as HMAC-SHA256 hashes under a server-side pepper, so a store compromise yields no reversible secret. Upstream provider credentials sit in an AEAD envelope. No log line, metric label, or cache key carries either one; the label that does travel is a policy-bucket id, not the key.
Config: key_management: (pepper, crypto.master_key) plus a
credentials: block (type: ai_provider). Signal:
sbproxy_inbound_key_requests_total{provider,key_mode,tenant_id,api_key_id},
where api_key_id is the secret-free bucket id. Proof:
crates/sbproxy-keystore/src/crypto.rs::verify_rejects_wrong_pepper,
::envelope_rejects_wrong_master_and_wrong_record,
::key_crypto_handle_combines_hash_and_envelope.
key-management.md.
examples/ai-dynamic-keys/.
2. Cross-tenant boundaries
Cache keys bind tenant and credential scope through domain-separated digests, so a tenant or credential change produces a different namespace rather than a collision. Request budgets key by tenant. A panicking tenant policy denies that one request with a 500 instead of taking the process down with it.
Config: tenants: scope resolution as described in multi-tenant.md. Signal:
sbproxy_policy_panic_total{policy}. Proof:
crates/sbproxy-ai/src/semantic_cache/identity.rs::namespace_changes_with_tenant.
multi-tenant.md.
examples/multi-tenant-saas/.
Named limit: co-tenancy is still soft isolation. Tenants share one process, so a fault outside policy evaluation, not the panic-catch this control adds, can still reach every tenant. That is why the recommendation stands regardless: run one process per trust boundary for tenants that do not trust each other. A gateway cannot turn a shared process into hard isolation by adding more catch blocks to it.
3. Routing decisions leave a record
route.decide, cache.key, and cache.admit are decision-audit points.
Opt one in and it publishes an OCSF record naming the rule and the reason
it fired; whether or not the audit feed is on, a metric family counts every
arm the decision could take, including declines, faults, and timeouts, so a
quiet feed is distinguishable from a feed that never wired up.
Config: proxy.observability.log.decision_audit.events.route.decide: true
(composes across origin, tenant, and proxy scope, per event label). Signal:
sbproxy_decision_event_total{event="route.decide",outcome},
sbproxy_decision_audit_events_total{event,outcome}.
observability.md,
decision-records.md.
Named limit: twelve of the twenty possible decision events publish under
their own label today (auth, cache.admit, cache.key, cache.reserve.health, route.decide,
ai.guardrail.input, ai.guardrail.output, ai.tool_call, ai.close,
ai.failure, ai.admission, mcp.tool), plus policy under the newer
record format. The other seven are not one undifferentiated leftover; the
code names four deliberate states for them.
waf and rate_limit are superseded by policy: both already run in the
policy chain and publish as a policy record naming which one fired, so a
second emitter would double-record one decision rather than add coverage.
payment.lifecycle is recorded durably elsewhere: money lands on the
durable settlement store, and this feed drops records under load, which is
the wrong trade for a receipt. ai.stream.event never publishes: it fires
once per streamed chunk, so config load refuses ai.stream.event: true
outright rather than accept an ingest bill (ai.close carries the
stream's summary once instead). The remaining three, transform,
action, and log.custom_field, are genuinely unwired, no emitter yet.
An events: key naming a label this proxy does
not recognize fails config load; a known but unwired label loads and
warns at boot instead, because refusing every label a later release might
wire would block pre-configuring it today. Proof:
crates/sbproxy-config/src/compiler.rs::decision_audit_refuses_an_unknown_event_label
(the unrecognized-label refusal),
::decision_audit_refuses_the_per_chunk_stream_event (the
ai.stream.event refusal), and
crates/sbproxy-core/src/server/tests.rs::a_superseded_event_is_reported_separately_from_an_unwired_one,
::the_unwired_warning_follows_the_policy_record_format (the boot warn,
which lives in lifecycle.rs::warn_unwired_decision_audit_events).
4. Denial of wallet is enforcement, not observation
Budgets deny at the cap across seven scopes (workspace, API key, user,
model, origin, tag, agent), and on_exceed: block actually blocks rather
than logging past it. Degraded enforcement is alertable rather than silent:
per-instance counters multiply the cap by replica count unless a shared
store backs them, and a store outage degrades to the per-instance floor
with a metric that says so.
Config: budget: on_exceed: block plus limits: [...] under action.
Signal: sbproxy_budget_share_fail_open_total{op},
sbproxy_budget_share_unavailable. Proof:
e2e/tests/ai_budget.rs::budget_block_returns_402_after_cap_exceeded.
ai-gateway.md.
examples/ai-budget/,
examples/ai-predictive-budget/.
5. Streaming parity
A guardrail verdict on a streamed response must equal the verdict the same
bytes would get buffered whole. A mode that cannot keep that promise is
refused at config compile rather than approximated: stream_policy: chunk
is rejected on a classifier-backed guard because full-text classification
is not prefix-stable, and the config never loads to find that out at
request time.
Config: guardrails: output: [...] stream_policy: close | chunk | off.
Signal:
sbproxy_ai_safety_guardrail_verdicts_total{guardrail,class,backend,verdict}.
Proof:
crates/sbproxy-ai/src/guardrails/stream.rs::streamed_verdicts_match_buffered_for_all_chunkings.
ai-gateway.md.
examples/ai-streaming/,
examples/ai-safety-classifiers/.
6. Telemetry does not leak the traffic it audits
Secret-regex and field-key redaction run before observability fan-out, on every emitter: access log, error log, security audit, and trace alike. A value redacted from one never reappears in another. Prompt-linked audit carries digests and lengths; verbatim content is an explicit opt-in, never a default.
Config: proxy.observability.log.redact.fields, .patterns, .pii.
Proof: e2e/tests/redaction.rs::redaction_per_sink_fan_out.
observability.md.
examples/pii-redaction/.
7. Egress is inventoried
Every outbound destination the gateway reaches, across fourteen wired
egress purposes, is recorded with its authorization status and last-seen
time, readable from the admin API. The purposes, by their inventory and
metric labels, are ai_provider, ai_judge (the dual-LLM quarantine
judge), agent_orchestration (agent endpoints invoked by configured AI
toolkit workflows), classifier_hook (the stock intent and
provider-quality RPCs), mcp_upstream, openapi_tool, token_exchange,
webhook, usage_sink, model_artifact, engine_artifact,
bundle_hook, federation (OpenID Federation entity-configuration and
subordinate-statement fetches), and telemetry.
Config: the top-level egress: block arms nine of the fourteen through
eight sub-blocks (ai_providers, agent_orchestration for AI toolkit
agent endpoints, classifier_hooks covers the stock intent and
provider-quality RPCs, usage_sinks covers both usage sinks and webhooks
including the events: sink, model_artifacts,
token_exchange for every token endpoint, the non-MCP resolver's and
the MCP run-as-user exchange's alike, federation, telemetry), each
mode: deny_by_default. MCP upstream connects, OpenAPI-backed MCP tools,
and the dual-LLM quarantine judge arm from a per-server or per-action
egress: block instead. Extension bundle hooks are always armed
automatically from the bundle's own outbound grant. Engine artifact
downloads pass no authorizer today and cannot be armed by any config.
agent_orchestration is the one purpose that fails closed: a configured
agent refuses to dial at all until its sub-block arms it. federation is
the one purpose with a floor under it: whether or not the sub-block is
present, a peer URL that resolves to a private, loopback, or link-local
address is refused before any connect, because a federation peer arrives
signed by another entity in the chain rather than written by this
operator.
Signal: GET /api/egress,
sbproxy_egress_refused_total{purpose,reason,tenant,origin}. Proof:
crates/sbproxy-security/src/egress.rs::egress_seen_records_a_single_sighting_with_counts,
::serialized_sighting_never_carries_userinfo_or_query (the inventory
itself never carries a credential embedded in a dialed URL).
configuration.md,
admin-api-reference.md.
Named limit: traffic that never reaches the gateway is invisible to it, an
authorizer can only authorize what it is handed. sbproxy ai ledger reconcile compensates after the fact: it compares the gateway's own usage
ledger against a usage export downloaded directly from the provider and
flags provider-billed usage the ledger never recorded, which is exactly the
shape a bypass leaves behind. The deployment recipe, a network policy that
permits provider egress only from the gateway's own identity, is part of
the control rather than an afterthought.
threat-model.md,
ai-usage-ledger.md.
8. Change control is tamper-evident
Security, config, key-mutation, and admin-action records each append, when the operator opts that channel in, to their own SHA-256 hash-chained, Ed25519-signed file. A chain that cannot open fails the boot rather than silently running without it. An append failure after boot is a non-ok outcome on the emit metric, so a quiet alert means an intact trail rather than a broken sensor.
Config: audit: sink: chain, path, sign_with, plus config_path,
key_path, admin_path to opt in the other three channels. Signal:
sbproxy_audit_emit_duration_seconds{channel,outcome!="ok"}. Proof, one
test per channel:
crates/sbproxy-observe/src/audit_chain.rs::a_signed_chain_of_denials_verifies_against_its_key
(security),
::an_installed_config_chain_takes_what_append_config_audit_is_given
(config),
::an_installed_key_chain_takes_what_append_key_audit_is_given (key),
::an_installed_admin_chain_takes_what_append_admin_audit_is_given
(admin), and ::a_torn_final_line_keeps_the_chain_closed for the
boot-refuses-on-corruption property. audit-log.md.
examples/audit-log/.
Named limit: each channel is opt-in, and none is chained until its path is set, so a deployment that never names one keeps that channel as a tracing stream only. There is also no rotation or segmentation: each chain is one file that grows, because truncating it is by construction indistinguishable from tampering with it. Size it accordingly and archive by copy, never by trim.
OWASP LLM Top 10 (2026) mapping
The public taxonomy, current as of the 2026-08-03 edition. Each row links to a section carrying its coverage word (enforced, enforced with named limits, or out of gateway scope), the risk in one line, what the gateway enforces with the config keys that turn it on, where to turn it on, the named proof, and the honest limits. The third column here is what's enforced in one clause, not a repeated grade.
| # | Risk | What sbproxy enforces |
|---|---|---|
| LLM01 | Prompt Injection | Input/output injection guardrail, double-pass RAG screening, multipart refusal on JSON-only surfaces |
| LLM02 | Sensitive Information Disclosure | pii: body redaction, plus secret-regex and field-key redaction on every log emitter |
| LLM03 | Excessive Agency | Per-tool RBAC with default-deny, resolved-model gates, per-agent budgets on verified identity |
| LLM04 | Supply Chain | Default-deny, DNS-pinned egress authorizer across fourteen purposes, with per-hop redirect re-authorization |
| LLM05 | Model & Data Poisoning | Out of gateway scope: risk lives with the model provider's training pipeline |
| LLM06 | Misinformation | Out of gateway scope: risk lives in the model's own generation |
| LLM07 | Unbounded Consumption | Budgets deny at the cap across seven scopes; per-instance until a shared store is present |
| LLM08 | Hidden Context Exposure | Domain-separated cache-key digests, digest-only prompt-linked audit |
| LLM09 | Vector & Embedding Weaknesses | Pre- and post-retrieval guardrail passes, tenant-scoped vector search |
| LLM10 | Improper Output Handling | Output guardrails with streaming/buffered verdict parity; blocked responses never cache |
LLM01: Prompt Injection
Enforced, with named limits.
Risk. Attacker-controlled text in a prompt, or in retrieved context, tries to override the model's instructions or exfiltrate what it was told not to repeat.
What sbproxy enforces. The built-in injection guardrail runs on input
and output, in mode: keyword or mode: classifier, with streaming
verdict parity. A rag: route runs input guardrails twice: once over the
original request, so a rejected prompt never causes embedding egress, and
again over the augmented request, so retrieved text gets the same screening
user text got. A multipart Content-Type on a JSON-only surface such as
chat_completions is refused with 403 before any guardrail, budget, or
upstream work runs, so a caller cannot relabel a request past body
inspection.
Turn it on. guardrails: input: - type: injection (add mode: classifier with a pinned ONNX model for the enforcing path); rag: on an
ai_proxy origin for the double-pass screening.
guardrails.md, ai-gateway.md,
rag.md. Examples:
ai-guardrails/,
ai-rag-local/.
Proof.
e2e/tests/ai_rag.rs::original_guardrail_blocks_before_embedding_egress,
::retrieved_poison_is_blocked_before_model_egress,
crates/sbproxy-core/src/server/ai_dispatch.rs::multipart_on_a_json_surface_is_refused_and_not_counted_as_skipped,
crates/sbproxy-ai/src/guardrails/stream.rs::streamed_verdicts_match_buffered_for_all_chunkings.
Limits. mode: keyword, the default, is case-insensitive substring
matching; it does not understand paraphrase, obfuscation, or translation,
and the docs say so rather than describing it as detection. mode: classifier ships no first-party injection model in the binary; the
operator supplies the artifact. Rationale: shipping a bundled
injection-detection model would be a claim about accuracy against traffic
we have never seen, for every deployment that runs it. The classifier hook
and the guardrail mesh's quorum fusion exist so an operator can attach a
detector they trust, or combine several weak ones, instead of trusting one
we picked for them.
LLM02: Sensitive Information Disclosure
Enforced, with named limits.
Risk. A prompt or a completion carries a secret, PII, or other sensitive data that leaks into a log, a cache, or a downstream sink.
What sbproxy enforces. The pii: block redacts AI request and response
bodies for well-known shapes (SSN, Luhn-validated card numbers, phone,
IPv4, common API key formats). Secret-regex and field-key redaction cover
structured logs, access log lines, and request-event sinks independently of
that block. Prompt-linked audit records carry salted digests of the
content, never the content itself.
Turn it on. pii: enabled: true on an ai_proxy origin;
proxy.observability.log.redact: for the log-side pass, which runs
whether or not pii: is configured. ai-gateway.md,
observability.md. Example:
pii-redaction/.
Proof. e2e/tests/redaction.rs::redaction_per_sink_fan_out.
Limits. The dlp policy scans the request URI and the request headers.
scan_body defaults true and body_max_bytes defaults 16384, but the
header-phase policy chain snapshots an empty body, so a secret that appears
only in the POST body is not seen. Its
actions are tag and block; it never masks. It is request-side only:
direction: response or both is accepted and warned about at config
load, and the request-side scan runs regardless, because the policy
enforcement phase has no response body to hand it. So pii: remains the
masking control and the response-direction control, and dlp is the
request-side detect-and-refuse one; the two stay separate rather than one
doing both jobs partway.
LLM03: Excessive Agency
Enforced, with named limits.
Risk. An agent or a tool call reaches further than the caller should be allowed to: a tool nobody granted, a model outside its allow list, or spend attributed to the wrong identity.
What sbproxy enforces. MCP tool access runs through per-tool RBAC with
default_allow: false, so a tool nobody granted is refused rather than
reachable. Every model gate, allowed_models, blocked_models, credential
model lists, budgets, and per-model rate limits, judges the resolved model
id, so an alias is never a way around a block list. Per-agent budgets key
on a verified caller identity (a peer in proxy.trusted_proxies, or the
RFC 8693 act chain of a signed token); an unverified caller pools into a
shared, still-capped bucket instead of spending against a name it did not
prove.
Turn it on. rbac_policies: under an mcp action;
allowed_models/blocked_models on a credentials: entry;
scope: agent under budget.limits. mcp-security.md,
mcp.md,
ai-gateway.md. Example:
admin-mcp/.
Proof.
crates/sbproxy-extension/src/mcp/access_control.rs::default_deny_unknown_caller_denied,
::default_deny_empty_allowed_means_deny_all for the RBAC seam;
e2e/tests/ai_budget.rs::budget_block_returns_402_after_cap_exceeded for
the agent-budget enforcement path.
Limits. None of this reaches a credential the agent already holds and types into a tool argument as plain text: a tool description reads as instruction to the model, and a secret already inside that conversation never crosses a seam a gateway policy can inspect. Rationale and compensation: mcp-security.md is explicit that this stays the caller's responsibility, and the recommendation is to keep long-lived secrets out of agent context in the first place rather than to expect a proxy to unsee one already there.
LLM04: Supply Chain
Enforced, with named limits.
Risk. The gateway or something it calls on your behalf reaches an outbound destination it should not: a compromised registry, a redirected webhook, a rebound DNS answer.
What sbproxy enforces. A default-deny, DNS-pinned egress authorizer
covers fourteen wired purposes once armed (see control 7
above for which config surface arms which purpose; engine artifact
downloads cannot be armed today and stay ungated). Every redirect hop is
re-authorized as a new destination, capped at
ten hops, with credentials stripped on any cross-origin hop, including
vendor header names such as x-api-key that an HTTP client's own
stripping does not cover. OTLP telemetry exporters are authorized once at
boot, where each is constructed; a denied endpoint refuses boot naming it,
rather than starting and silently exporting nowhere useful. Self-hosted
model and engine artifacts are pinned by SHA-256 and signature-verified
before the model host serves them.
Turn it on. The top-level egress: block, mode: deny_by_default per
purpose. configuration.md,
threat-model.md.
Proof.
crates/sbproxy-security/src/egress.rs::dial_time_rebind_is_refused_with_dns_pin_mismatch,
::cross_origin_redirect_strips_credentials,
crates/sbproxy-observe/src/telemetry.rs::denied_telemetry_endpoint_is_stamped_and_would_refuse_boot.
Limits. No model weights ship in this repository, so there is nothing here to attest about training data; that risk is LLM05, out of gateway scope. Four credential-bearing consumers (AI providers, usage sinks and webhooks, token exchange, artifact downloads) authorize against a live DNS resolver behind a 30-second cache but are not dial-pinned the way the OpenAPI tool path is. Rationale: dial pinning needs one HTTP client per destination, and these four share one long-lived client each because the AI client is the hottest outbound path in the proxy; a client per destination would rebuild TLS and abandon the connection pool on every dial, a latency regression on every AI request to close a window the resolver cache already narrows to its TTL. It compensates in two ways: the allowlist, scheme, and port checks still hold, so exploiting the gap requires already being on the operator's allowlist, and the per-destination client cache that would close it is scoped and stated rather than left implicit. threat-model.md.
LLM05: Model & Data Poisoning
Out of gateway scope.
Risk lives with the model provider's training pipeline for hosted models, or with whatever fine-tunes or indexes a corpus for a self-hosted one. By the time a poisoned model or a poisoned document reaches the gateway, the poisoning already happened upstream of it.
What sbproxy contributes adjacent to it. For self-hosted model artifacts, the model host pins by SHA-256 and verifies signatures before serving; that is supply-chain integrity for the artifact (LLM04), not a claim about what the model learned. For gateway-performed RAG, retrieved text passes the same input guardrails user text does before it is injected as context (LLM09); that screens the delivery mechanism, not the corpus it draws from. A proxy on the request path cannot attest what a hosted vendor trained on, or audit a corpus it never indexed.
LLM06: Misinformation
Out of gateway scope.
Risk lives in the model's own generation. Whether an answer is correct is a property of the model and the prompt, not of the network path a request took to reach it.
What sbproxy contributes adjacent to it. Shadow evaluation mirrors a sampled request to a second provider for latency, cost, and output comparison, and judge-model routing selects among providers on the same basis. Neither is built as a correctness check, and neither is claimed as one: a routing feature is not a fact-checker, and describing it as such would be exactly the kind of claim this page exists to avoid making. ai-gateway.md.
LLM07: Unbounded Consumption
Enforced, with named limits.
Risk. A caller, or a compromised credential, runs up spend or resource consumption with no ceiling, whether that is deliberate denial-of-wallet or an accidental runaway agent loop.
What sbproxy enforces. Budgets deny at the cap across seven scopes
(workspace, API key, user, model, origin, tag, agent) with on_exceed: block | log | downgrade. Soft-landing thresholds taper before the hard
cap: a warn threshold logs, a downgrade threshold rewrites the model, and
only the cap itself blocks. Realtime WebSocket requests run the same
hard-limit preflight before the upgrade. Counters are cluster-shared
whenever a Redis key store is present, with no extra configuration needed.
Turn it on. budget: on_exceed: block plus limits: [...] under
action; soft_landing: for the taper.
ai-gateway.md. Examples:
ai-budget/,
ai-predictive-budget/.
Proof. e2e/tests/ai_budget.rs::budget_block_returns_402_after_cap_exceeded.
Limits. Counters are per-instance by default, so a cluster of N
replicas enforces roughly N times a configured cap until a Redis-backed key
store is present. If that store becomes briefly unreachable, enforcement
degrades to the per-instance floor rather than failing open entirely, and
sbproxy_budget_share_fail_open_total{op} and
sbproxy_budget_share_unavailable say so on a dashboard instead of in
silence. GET and multipart AI surfaces do not debit token budgets, because
byte-transparent forwarding never parses a token count to charge; Realtime
frames are the same case, metered at the connection preflight rather than
per frame. The floor that does apply, the per-instance count, the
preflight check, still holds while the gap is visible.
LLM08: Hidden Context Exposure
Enforced, with named limits.
Risk. A system prompt, injected RAG context, or other server-side detail leaks to a caller who should not see it, through an error message, a cache key, or a log line.
What sbproxy enforces. System prompts and injected RAG context are not
echoed through the gateway's generic error path; private detail stays in
bounded logs and metrics. Cache keys are domain-separated digests
(semantic_prompt_digest, semantic_origin_route_digest, and similar)
that carry no prompt, tenant, subject, or model text, only a hash of it.
Prompt-linked audit lines carry salted digests and lengths, never content.
Turn it on. This is default behavior on the caching and audit paths; there is no separate flag. ai-gateway.md, audit-log.md.
Proof.
crates/sbproxy-ai/src/semantic_cache/identity.rs::namespace_changes_with_tenant,
::namespace_changes_with_api_key_id.
Limits. One field on the audit trail is operator-authored rather than
system-generated: a policy's deny reason. It is written verbatim, in the
tracing target and in the signed chain alike, so a deny message that
interpolates request data carries that data into both. Rationale: a deny
reason is deliberately human-written text meant to explain a decision, and
silently truncating or hashing it would defeat the reason the audit trail
exists. Compensation: the same redact.patterns: regex masks that cover
log lines also run over this field, so a rule written for one covers the
other on the same reload; write deny messages that name the rule rather
than the content, and add a pattern for anything else.
audit-log.md.
LLM09: Vector & Embedding Weaknesses
Enforced.
Risk. A retrieval-augmented pipeline embeds or indexes something an attacker controls, or serves a tenant's vectors to a caller who should not reach them.
What sbproxy enforces. A rag: route runs input guardrails before
embedding, so a rejected prompt causes no embedding egress, and again over
the augmented request, so retrieved text gets the same screening as user
text before it reaches the model. Vector search is tenant-scoped: the
tenant filter comes only from the origin's own configured tenant_id,
never from a caller-supplied value.
Turn it on. rag: on an ai_proxy origin, with filters.tenant_field
set. rag.md. Example:
ai-rag-local/.
Proof.
e2e/tests/ai_rag.rs::original_guardrail_blocks_before_embedding_egress,
::retrieved_poison_is_blocked_before_model_egress,
::rag_injects_tenant_scoped_context_before_provider_dispatch.
LLM10: Improper Output Handling
Enforced, with named limits.
Risk. Model output reaches a client, or gets admitted to cache, without validation: an unvalidated payload, broken JSON hitting a downstream parser, or a flagged fragment slipping through a stream.
What sbproxy enforces. Output guardrails, including schema validation, run on streaming responses with the same verdict the buffered path would reach. A live-evaluated guard withholds only the violating chunk and everything after it. A classifier-backed close-policy guard holds the entire response body, so a block releases no bytes at all. A blocked response is never admitted to cache.
Turn it on. guardrails: output: - type: schema (or any output
guardrail type). ai-gateway.md,
guardrails.md. Examples:
ai-guardrails/,
ai-safety-classifiers/.
Proof.
crates/sbproxy-ai/src/guardrails/stream.rs::streamed_verdicts_match_buffered_for_all_chunkings.
Limits. Response headers may already be sent by the time a streaming block fires, so a blocked stream ends empty rather than getting re-statused to an error code; the client sees a truncated stream, not a 4xx. Rationale: HTTP has no way to rewrite a status line after the first byte of a chunked body has gone out, so once headers are sent the only fail-closed option is to stop the body rather than claim a status that was never true. The alternative, buffering every response before sending headers so a status code is always still available, would remove streaming's latency benefit from every request to protect the rare one that gets blocked.
Signals
| Control | Log target | Metric |
|---|---|---|
| Multipart surface refusal | security_audit (multipart_disallowed_surface, policy_denied via events sink) |
sbproxy_audit_emit_duration_seconds{channel="security"} (no dedicated counter; the security-channel audit-emission metric below) |
| Multipart inspection skip (allowed surfaces) | none | sbproxy_ai_multipart_inspection_skipped_total{check,surface}, legitimate multipart traffic that skipped body inspection, not a refusal |
| Budget fail-open | sbproxy::budget WARN on transition |
sbproxy_budget_share_fail_open_total{op}, sbproxy_budget_share_unavailable |
| Tenant throttle | decision events | sbproxy_rate_limit_total{workspace}, sbproxy_rate_limit_decisions_total{policy} |
| Policy panic | security_audit ERROR |
sbproxy_policy_panic_total{policy} |
| Egress sightings | sbproxy::egress WARN on refusal |
sbproxy_egress_refused_total{purpose,reason,tenant,origin}, inventory at GET /api/egress |
| Audit chain health | security_audit / config_audit / key_audit / sbproxy::admin::audit |
sbproxy_audit_emit_duration_seconds{channel,outcome!="ok"} |
| RAG retrieval outcome | sbproxy_ai_rag target |
sbproxy_ai_rag_requests_total{embedding,vector_store,outcome} |
| Routing decision audit | decision_audit_event: (stderr) |
sbproxy_decision_event_total{event,engine,outcome,origin,tenant}, sbproxy_decision_audit_events_total{event,outcome} |
| Inbound key resolution | access log, request events | sbproxy_inbound_key_requests_total{provider,key_mode,tenant_id,api_key_id} |