mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-30 03:21:29 +02:00
[proxy,management] Conform the Agent Network endpoint to the LLM gateway protocol Reviewed the proxy against Claude Code's published gateway contract. The transport layer already held up; fourteen gaps sat one layer up, in the model catalog and in the non-inference endpoints clients call. Two of them cost money. The catalog carried no claude-opus-5 or claude-sonnet-5, so an operator could not authorise the models coding agents default to — those requests denied as not-routable, or priced at zero where a catch-all carried them. And gateway records pin ParserID "openai" while the same record serves /v1/messages, so Anthropic responses were read with the OpenAI parser, which never looks at message_start where input tokens live: input metered as roughly zero on every stream and cost was skipped entirely. The rest fix requests refused for structural rather than policy reasons: model discovery denied for every account with a model allowlist, token counting denied on Bedrock and mis-parsed on Vertex, startup probes refused and written into the access log at every session start, and denials rendered in a shape no LLM client parses. Two changes are additive by design — the deny body keeps every field it had and adds the vendor's error object alongside, and body-level identity injection is now gated on the request's dialect so it stops sending OpenAI-shape fields into Anthropic bodies that reject them. The end-to-end work turned up one more: the discovery filter treated any slash in a model id as a gateway prefix, which would have dropped every self-hosted "Qwen/..." model from the picker.
234 lines
8.4 KiB
Go
234 lines
8.4 KiB
Go
// Package llm_guardrail implements the SlotOnRequest middleware that
|
|
// enforces the per-target LLM guardrail policy: a model allowlist
|
|
// check and an opt-in prompt-capture step that may run a PII redactor
|
|
// before emitting the prompt into the metadata bag.
|
|
//
|
|
// The middleware runs after llm_request_parser, which is responsible
|
|
// for extracting the model and raw prompt onto the metadata side
|
|
// channel. llm_guardrail consumes those keys, decides allow/deny, and
|
|
// emits its own decision metadata plus the optional redacted prompt.
|
|
package llm_guardrail
|
|
|
|
import (
|
|
"context"
|
|
"unicode/utf8"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/middleware"
|
|
)
|
|
|
|
// ID is the registry key for this middleware.
|
|
const ID = "llm_guardrail"
|
|
|
|
const (
|
|
version = "1.0.0"
|
|
maxPromptBytes = 3500
|
|
denyCodeModel = "llm_policy.model_blocked"
|
|
denyReasonModel = "model_blocked"
|
|
denyMessageModel = "model is not in the policy allowlist"
|
|
// Deny reason used when an allowlist is configured but the request model
|
|
// could not be determined. URL/path-routed providers (AWS Bedrock, Google
|
|
// Vertex, ...) carry the model outside the JSON body, so a request shape the
|
|
// parser does not recognise reaches the guardrail with no model. Such a
|
|
// request must be denied (fail closed), never waved through.
|
|
denyCodeModelUnknown = "llm_policy.model_unknown"
|
|
denyReasonModelUnknown = "model_unknown"
|
|
denyMessageModelUnknown = "request model could not be determined for the policy allowlist"
|
|
)
|
|
|
|
// Middleware enforces the model allowlist and optionally captures the
|
|
// request prompt with PII redaction.
|
|
type Middleware struct {
|
|
cfg Config
|
|
}
|
|
|
|
// New constructs a Middleware with the supplied configuration. Model
|
|
// allowlist entries are normalised so the runtime check is
|
|
// case-insensitive and trim-tolerant.
|
|
func New(cfg Config) *Middleware {
|
|
return &Middleware{cfg: normaliseConfig(cfg)}
|
|
}
|
|
|
|
// ID returns the registry identifier.
|
|
func (m *Middleware) ID() string { return ID }
|
|
|
|
// Version returns the implementation version.
|
|
func (m *Middleware) Version() string { return version }
|
|
|
|
// Slot reports the chain slot the middleware lives in.
|
|
func (m *Middleware) Slot() middleware.Slot { return middleware.SlotOnRequest }
|
|
|
|
// AcceptedContentTypes lists the request body content types the
|
|
// middleware needs. Guardrail consumes metadata produced upstream and
|
|
// does not touch the body itself, but we keep application/json so the
|
|
// body policy retains the parsed payload upstream when required.
|
|
func (m *Middleware) AcceptedContentTypes() []string {
|
|
return []string{"application/json"}
|
|
}
|
|
|
|
// MetadataKeys is the closed set of metadata keys this middleware may
|
|
// emit. The accumulator drops anything outside this allowlist.
|
|
func (m *Middleware) MetadataKeys() []string {
|
|
return []string{
|
|
middleware.KeyLLMPolicyDecision,
|
|
middleware.KeyLLMPolicyReason,
|
|
middleware.KeyLLMRequestPrompt,
|
|
}
|
|
}
|
|
|
|
// MutationsSupported reports whether the middleware emits header / body
|
|
// mutations. Guardrail never mutates the request.
|
|
func (m *Middleware) MutationsSupported() bool { return false }
|
|
|
|
// Invoke runs the policy. The model allowlist is the only deny path;
|
|
// prompt capture only affects the metadata emitted alongside an allow.
|
|
func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middleware.Output, error) {
|
|
model, modelPresent := lookupMetadata(in.Metadata, middleware.KeyLLMModel)
|
|
providerID, _ := lookupMetadata(in.Metadata, middleware.KeyLLMResolvedProviderID)
|
|
surface, _ := lookupMetadata(in.Metadata, middleware.KeyLLMProvider)
|
|
nonInference, _ := lookupMetadata(in.Metadata, middleware.KeyLLMNonInference)
|
|
|
|
if denial := m.evaluateAllowlist(providerID, surface, model, modelPresent, nonInference == "true"); denial != nil {
|
|
return denial, nil
|
|
}
|
|
|
|
out := &middleware.Output{
|
|
Decision: middleware.DecisionAllow,
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "allow"},
|
|
{Key: middleware.KeyLLMPolicyReason, Value: ""},
|
|
},
|
|
}
|
|
|
|
if prompt, ok := m.capturePrompt(in.Metadata); ok {
|
|
out.Metadata = append(out.Metadata, middleware.KV{
|
|
Key: middleware.KeyLLMRequestPrompt,
|
|
Value: prompt,
|
|
})
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// Close releases resources owned by the middleware. Stateless, so this
|
|
// is a no-op.
|
|
func (m *Middleware) Close() error { return nil }
|
|
|
|
// evaluateAllowlist denies when the resolved provider's allowlist rejects the
|
|
// model; nil means proceed. Scoped to the provider llm_router resolved, so an
|
|
// unrestricted provider (absent from config) is never caught by another's list.
|
|
func (m *Middleware) evaluateAllowlist(providerID, surface, model string, modelPresent, nonInference bool) *middleware.Output {
|
|
if len(m.cfg.ProviderAllowlists) == 0 {
|
|
return nil
|
|
}
|
|
// Restrictions exist but the resolved provider is unknown, so we can't tell
|
|
// if this request targets a restricted provider — fail closed. llm_router
|
|
// normally stamps the provider first, so this is a defensive guard.
|
|
if providerID == "" {
|
|
return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
|
|
}
|
|
allowlist, restricted := m.cfg.ProviderAllowlists[providerID]
|
|
if !restricted {
|
|
// This provider has no allowlist (some authorising policy left it
|
|
// unrestricted); management owns any per-policy/group decision.
|
|
return nil
|
|
}
|
|
// Fail closed: with an allowlist in effect for this provider, a request whose
|
|
// model the parser couldn't extract (absent/empty) is denied. This enforces
|
|
// the allowlist for path-routed providers (Bedrock, Vertex) with no body model.
|
|
//
|
|
// The exception is a non-inference endpoint the router already authorised.
|
|
// The model listing and the connection-warming probe name no model
|
|
// anywhere — not in a body, not in the path — so failing closed here
|
|
// rejected model discovery for exactly the accounts that configured an
|
|
// allowlist, which is the outage this endpoint is meant to avoid. The
|
|
// per-model lookup does name one (the router stamps it from the path), so
|
|
// it still falls through to the allowlist check below.
|
|
if !modelPresent || normaliseModel(model) == "" {
|
|
if nonInference {
|
|
return nil
|
|
}
|
|
return denyModel(surface, "", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
|
|
}
|
|
if modelInAllowlist(allowlist, model) {
|
|
return nil
|
|
}
|
|
return denyModel(surface, model, denyCodeModel, denyMessageModel, denyReasonModel)
|
|
}
|
|
|
|
// denyModel builds a 403 deny Output for a model-allowlist rejection. model is
|
|
// included in the details only when non-empty.
|
|
func denyModel(surface, model, code, message, reason string) *middleware.Output {
|
|
details := map[string]string{}
|
|
if model != "" {
|
|
details["model"] = model
|
|
}
|
|
return &middleware.Output{
|
|
Decision: middleware.DecisionDeny,
|
|
DenyStatus: 403,
|
|
DenyReason: &middleware.DenyReason{
|
|
Code: code,
|
|
Message: message,
|
|
Details: details,
|
|
Surface: surface,
|
|
},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
|
{Key: middleware.KeyLLMPolicyReason, Value: reason},
|
|
},
|
|
}
|
|
}
|
|
|
|
// modelInAllowlist reports whether the model matches any entry in the supplied
|
|
// (already-normalised) allowlist under the case-insensitive, trim-tolerant
|
|
// comparison rule.
|
|
func modelInAllowlist(allowlist []string, model string) bool {
|
|
normalised := normaliseModel(model)
|
|
if normalised == "" {
|
|
return false
|
|
}
|
|
for _, allowed := range allowlist {
|
|
if allowed == normalised {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// capturePrompt returns the prompt to emit and whether it should be
|
|
// emitted at all. The truncation guarantee is upheld here regardless of
|
|
// whether redaction grew the string.
|
|
func (m *Middleware) capturePrompt(meta []middleware.KV) (string, bool) {
|
|
if !m.cfg.PromptCapture.Enabled {
|
|
return "", false
|
|
}
|
|
raw, ok := lookupMetadata(meta, middleware.KeyLLMRequestPromptRaw)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
prompt := raw
|
|
if m.cfg.PromptCapture.RedactPii {
|
|
prompt = redactPII(prompt)
|
|
}
|
|
if len(prompt) > maxPromptBytes {
|
|
// Back off to a UTF-8 rune boundary so we never emit a string
|
|
// split mid-rune.
|
|
cut := maxPromptBytes
|
|
for cut > 0 && !utf8.RuneStart(prompt[cut]) {
|
|
cut--
|
|
}
|
|
prompt = prompt[:cut]
|
|
}
|
|
return prompt, true
|
|
}
|
|
|
|
// lookupMetadata finds the first KV with the given key. Returns the
|
|
// value and true when present; the empty string and false otherwise.
|
|
func lookupMetadata(meta []middleware.KV, key string) (string, bool) {
|
|
for _, kv := range meta {
|
|
if kv.Key == key {
|
|
return kv.Value, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|