// 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" ) // 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) if denial := m.evaluateAllowlist(model, modelPresent); 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 returns a deny Output when the configured allowlist // rejects the model. A nil return means the request should proceed. func (m *Middleware) evaluateAllowlist(model string, modelPresent bool) *middleware.Output { if len(m.cfg.ModelAllowlist) == 0 { return nil } if !modelPresent { return nil } if m.modelInAllowlist(model) { return nil } return &middleware.Output{ Decision: middleware.DecisionDeny, DenyStatus: 403, DenyReason: &middleware.DenyReason{ Code: denyCodeModel, Message: denyMessageModel, Details: map[string]string{"model": model}, }, Metadata: []middleware.KV{ {Key: middleware.KeyLLMPolicyDecision, Value: "deny"}, {Key: middleware.KeyLLMPolicyReason, Value: denyReasonModel}, }, } } // modelInAllowlist reports whether the model matches any allowlist // entry under the case-insensitive, trim-tolerant comparison rule. func (m *Middleware) modelInAllowlist(model string) bool { normalised := normaliseModel(model) if normalised == "" { return false } for _, allowed := range m.cfg.ModelAllowlist { 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 }