mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-20 04:19:08 +02:00
## Describe your changes The Agent Network policy Guardrail "Model Allowlist" was not enforced for providers whose model travels in the URL/path rather than the JSON body — most visibly AWS Bedrock (reported in netbirdio/netbird#6751), and the same class applies to Google Vertex. Root cause: the `llm_guardrail` allowlist check **failed open**. `evaluateAllowlist` returned allow whenever the request model was absent from the metadata bag (`middleware.go`, `if !modelPresent { return nil }`). The model is stamped upstream by `llm_request_parser`; for body-routed providers (OpenAI/Anthropic) it comes from the JSON body, but for path-routed providers the model is recovered only when the request matches a recognized path shape (Bedrock `/model/{id}/{invoke|converse|...}`, Vertex `/v1/projects/.../publishers/.../models/...`). Any shape the parser did not recognize reached the guardrail with no model and was allowed regardless of the allowlist. Fix (provider-agnostic): **fail closed**. When an allowlist is configured and the model cannot be determined (absent or empty), the request is denied `403` with a distinct `llm_policy.model_unknown` reason. This closes the bypass for Bedrock, Vertex, and any future URL-routed provider in one place. When no allowlist is configured, behavior is unchanged. The model allowlist is enforced solely in the proxy `llm_guardrail`; management's `CheckLLMPolicyLimits` handles only token/budget caps, so no management change is required. ## Issue ticket number and link <https://github.com/netbirdio/netbird/discussions/6751> ## Stack - \#6726 <!-- branch-stack --> - \#6764 :point\_left: ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] Is a feature enhancement - [ ] It is a refactor - [x] Created tests that fail without the change (if possible) - [x] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first). > By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) Bug fix that restores the documented allowlist behavior; no user-facing surface changes. ### Docs PR URL (required if "docs added" is checked) Paste the PR link from <https://github.com/netbirdio/docs> here: <https://github.com/netbirdio/docs/pull/>\_\_ ## Tests - `llm_guardrail`: absent/empty model under a configured allowlist now denies (`model_unknown`); empty allowlist still allows a missing model (fail-closed only applies when a list is set); existing allow/deny/case-insensitive cases retained. - `llm_request_parser`: new parser→guardrail integration test drives real **Bedrock** (`/model/{id}/invoke`) and **Vertex** (`/v1/projects/.../models/...`) URL shapes and asserts allowed→200, disallowed→403 (`model_blocked`), and an unrecognized Bedrock action→403 (`model_unknown`, the #6751 regression guard). Note: a full through-tunnel e2e for the allowlist is intentionally deferred — the agent-network e2e (`WaitProxyPeer`) is currently red on `main`/`0.74.x` for an unrelated lazy-connection reason; it will be added once that harness gate is fixed.
206 lines
6.8 KiB
Go
206 lines
6.8 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)
|
|
|
|
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
|
|
}
|
|
// Fail closed: with an allowlist configured, a request whose model the
|
|
// upstream parser could not extract (absent or empty) must be denied rather
|
|
// than allowed. This is what enforces the allowlist for URL/path-routed
|
|
// providers (Bedrock, Vertex, ...) whose model lives outside the JSON body.
|
|
if !modelPresent || normaliseModel(model) == "" {
|
|
return denyModel("", denyCodeModelUnknown, denyMessageModelUnknown, denyReasonModelUnknown)
|
|
}
|
|
if m.modelInAllowlist(model) {
|
|
return nil
|
|
}
|
|
return denyModel(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(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,
|
|
},
|
|
Metadata: []middleware.KV{
|
|
{Key: middleware.KeyLLMPolicyDecision, Value: "deny"},
|
|
{Key: middleware.KeyLLMPolicyReason, Value: reason},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|