mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-01 11:09:15 +02:00
* [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly.
184 lines
5.6 KiB
Go
184 lines
5.6 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"
|
|
)
|
|
|
|
// 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
|
|
}
|