CI / test (push) Successful in 9m48s
- The similar-question cache embeds a question as its project would: a project kept in the cluster has external fallbacks refused, and an embedding model outside the cluster is not asked at all. The vectors are compared outside the cache's lock. The question's bound is in characters. - The restorer puts values back in streamed tool calls' arguments (escaped for the JSON they are), completions' text and reasoning, each held across events on its own, and releases a held tail with the finish event. A whole answer is walked as JSON, so a value with a quote no longer breaks tool-call arguments. - An outside guardrail service reads the user's turn as redacted, and a streamed answer once rather than at the finish chunk and again at the end. guardrails.md says it can stop a stream only under buffer_all. - A replica's smoothed time to first token is forgotten after five minutes without an answer: one found slow and then given nothing was never tried again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
587 lines
15 KiB
Go
587 lines
15 KiB
Go
package guard
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/guard/detect"
|
|
)
|
|
|
|
// Attachment ties a policy to a part of the tenancy tree: scope is org, team,
|
|
// project, user or key, and ScopeID which one.
|
|
type Attachment struct {
|
|
Scope, ScopeID, Policy string
|
|
}
|
|
|
|
// Attachments is where they are kept: the store.
|
|
type Attachments interface {
|
|
GuardAttachments(ctx context.Context) ([]Attachment, error)
|
|
}
|
|
|
|
// Scopes, from the widest.
|
|
var Scopes = []string{"org", "team", "project", "user", "key"}
|
|
|
|
// Gate is the Guard the gateway runs: the policy library, the attachments,
|
|
// and the detectors.
|
|
type Gate struct {
|
|
cfg Config
|
|
store Attachments
|
|
log *slog.Logger
|
|
now func() time.Time
|
|
|
|
deny atomic.Pointer[map[string]*regexp.Regexp] // by pattern regex
|
|
tools toolMemo
|
|
judger *judge
|
|
m *gateMetrics
|
|
// alarm is told each time a judge gives no verdict; nil tells no one.
|
|
alarm func(model string, err error)
|
|
// lastErr is each judge's last failure to give a verdict.
|
|
errMu sync.Mutex
|
|
lastErr map[string]JudgeError
|
|
|
|
mu sync.Mutex
|
|
attached map[string][]string // "scope/id" → policy names
|
|
loadedAt time.Time
|
|
loading bool
|
|
merged map[string]Policy // by the chain of names
|
|
}
|
|
|
|
// NewGate builds the gate. The library must be valid.
|
|
func NewGate(cfg Config, store Attachments, log *slog.Logger) (*Gate, error) {
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
g := &Gate{cfg: cfg, store: store, log: log, now: time.Now,
|
|
merged: map[string]Policy{}, m: newGateMetrics()}
|
|
g.deny.Store(compileDeny(cfg))
|
|
g.reload(context.Background())
|
|
return g, nil
|
|
}
|
|
|
|
func compileDeny(cfg Config) *map[string]*regexp.Regexp {
|
|
deny := map[string]*regexp.Regexp{}
|
|
for _, p := range cfg.Policies {
|
|
for _, r := range []Rules{p.Input, p.Output} {
|
|
for _, d := range r.Deny {
|
|
deny[d.Regex] = regexp.MustCompile(d.Regex)
|
|
}
|
|
}
|
|
}
|
|
return &deny
|
|
}
|
|
|
|
// Replace puts a new library of policies in force, for requests from the
|
|
// next one on: the cluster's settings changed. Refused, and the old one
|
|
// kept, when it is not valid.
|
|
func (g *Gate) Replace(cfg Config) error {
|
|
if err := cfg.Validate(); err != nil {
|
|
return err
|
|
}
|
|
// The patterns first: a policy naming one must find it compiled.
|
|
old := *g.deny.Load()
|
|
next := *compileDeny(cfg)
|
|
for k, v := range old {
|
|
if _, ok := next[k]; !ok {
|
|
next[k] = v
|
|
}
|
|
}
|
|
g.deny.Store(&next)
|
|
g.mu.Lock()
|
|
g.cfg = cfg
|
|
g.merged = map[string]Policy{}
|
|
g.mu.Unlock()
|
|
g.reload(context.Background())
|
|
return nil
|
|
}
|
|
|
|
// Library is the policies in force.
|
|
func (g *Gate) Library() Config {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.cfg
|
|
}
|
|
|
|
// attachmentsTTL is how stale an attachment may be: one made in the console
|
|
// applies within it.
|
|
const attachmentsTTL = 30 * time.Second
|
|
|
|
func (g *Gate) reload(ctx context.Context) {
|
|
if g.store == nil {
|
|
return
|
|
}
|
|
list, err := g.store.GuardAttachments(ctx)
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.loading = false
|
|
if err != nil {
|
|
// Kept as they were: a store that cannot be read for a moment is no
|
|
// reason to drop every policy.
|
|
g.log.Warn("could not read guard policy attachments; keeping the last ones", "err", err)
|
|
return
|
|
}
|
|
g.attached = map[string][]string{}
|
|
for _, a := range list {
|
|
if _, ok := g.cfg.Policies[a.Policy]; !ok {
|
|
g.log.Warn("a guard policy is attached that the cluster's settings do not define; ignored",
|
|
"policy", a.Policy, "scope", a.Scope, "id", a.ScopeID)
|
|
continue
|
|
}
|
|
k := a.Scope + "/" + a.ScopeID
|
|
g.attached[k] = append(g.attached[k], a.Policy)
|
|
}
|
|
for k := range g.attached {
|
|
sort.Strings(g.attached[k])
|
|
}
|
|
g.loadedAt = g.now()
|
|
g.merged = map[string]Policy{}
|
|
}
|
|
|
|
// Invalidate has attachments read again on the next request: one just made
|
|
// applies at once rather than within attachmentsTTL.
|
|
func (g *Gate) Invalidate() { g.reload(context.Background()) }
|
|
|
|
// Policy is the policy in force for a request, and the names it was made
|
|
// from.
|
|
func (g *Gate) Policy(s Subject) (Policy, string) {
|
|
g.mu.Lock()
|
|
if !g.cfg.On() {
|
|
g.mu.Unlock()
|
|
return Policy{}, ""
|
|
}
|
|
if !g.loading && g.store != nil && g.now().Sub(g.loadedAt) > attachmentsTTL {
|
|
g.loading = true
|
|
go g.reload(context.WithoutCancel(context.Background()))
|
|
}
|
|
names := append([]string{}, g.cfg.Default...)
|
|
for _, sc := range []struct{ scope, id string }{
|
|
{"org", s.OrgID}, {"team", s.TeamID}, {"project", s.ProjectID}, {"user", s.UserID}, {"key", s.KeyID},
|
|
} {
|
|
if sc.id != "" {
|
|
names = append(names, g.attached[sc.scope+"/"+sc.id]...)
|
|
}
|
|
}
|
|
names = dedupe(names)
|
|
key := strings.Join(names, ",")
|
|
p, ok := g.merged[key]
|
|
if !ok {
|
|
ps := make([]Policy, len(names))
|
|
for i, n := range names {
|
|
ps[i] = g.cfg.Policies[n]
|
|
}
|
|
p = Merge(names, ps)
|
|
g.merged[key] = p
|
|
}
|
|
g.mu.Unlock()
|
|
return p, key
|
|
}
|
|
|
|
func dedupe(in []string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, s := range in {
|
|
if !seen[s] {
|
|
seen[s] = true
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// finding is one reason to act, by name.
|
|
type finding struct {
|
|
action Action
|
|
category string
|
|
rule string
|
|
// failOpen is a judge that did not answer, under a policy that serves
|
|
// anyway.
|
|
failOpen bool
|
|
}
|
|
|
|
// judge scans one piece of text under one direction's rules, returning
|
|
// every finding and the spans to redact.
|
|
func (g *Gate) judge(text string, r Rules) ([]finding, []detect.Span) {
|
|
var fs []finding
|
|
var redact []detect.Span
|
|
if len(r.PII) > 0 {
|
|
for _, sp := range detect.Spans(text) {
|
|
a, ok := actions[r.PII[sp.Kind]]
|
|
if !ok {
|
|
continue
|
|
}
|
|
fs = append(fs, finding{action: a, category: sp.Kind, rule: "pii:" + sp.Kind})
|
|
if a == Redact {
|
|
redact = append(redact, sp)
|
|
}
|
|
}
|
|
}
|
|
if r.Injection != "" {
|
|
for _, k := range detect.Injection(text) {
|
|
fs = append(fs, finding{action: actions[r.Injection], category: "injection", rule: k})
|
|
}
|
|
}
|
|
if len(r.Deny) > 0 {
|
|
norm := detect.Normalize(text)
|
|
for _, d := range r.Deny {
|
|
re := (*g.deny.Load())[d.Regex]
|
|
if re == nil {
|
|
continue
|
|
}
|
|
a := actions[d.Action]
|
|
inText, inNorm := re.MatchString(text), re.MatchString(norm)
|
|
switch {
|
|
case !inText && !inNorm:
|
|
continue
|
|
case a == Redact && inText:
|
|
for _, m := range re.FindAllStringIndex(text, -1) {
|
|
redact = append(redact, detect.Span{Kind: d.Name, Start: m[0], End: m[1]})
|
|
}
|
|
case a == Redact:
|
|
// Only there once disguises are taken off, so there is no
|
|
// place in the text to cut it out of: stopped instead.
|
|
a = Block
|
|
}
|
|
fs = append(fs, finding{action: a, category: "deny", rule: d.Name})
|
|
}
|
|
}
|
|
return fs, redact
|
|
}
|
|
|
|
// strictest picks the finding that decides.
|
|
func strictest(fs []finding) (finding, bool) {
|
|
var best finding
|
|
found := false
|
|
for _, f := range fs {
|
|
if !found || rank(f.action) > rank(best.action) {
|
|
best, found = f, true
|
|
}
|
|
}
|
|
return best, found
|
|
}
|
|
|
|
// placeholders rewrites spans as [KIND_n], the same value always the same
|
|
// placeholder within one request.
|
|
type placeholders struct {
|
|
byValue map[string]string
|
|
count map[string]int
|
|
}
|
|
|
|
func newPlaceholders() *placeholders {
|
|
return &placeholders{byValue: map[string]string{}, count: map[string]int{}}
|
|
}
|
|
|
|
func (p *placeholders) rewrite(text string, spans []detect.Span) string {
|
|
if len(spans) == 0 {
|
|
return text
|
|
}
|
|
sort.Slice(spans, func(i, j int) bool { return spans[i].Start < spans[j].Start })
|
|
var b strings.Builder
|
|
last := 0
|
|
for _, s := range spans {
|
|
if s.Start < last {
|
|
continue // overlapping: the earlier already covers it
|
|
}
|
|
v := text[s.Start:s.End]
|
|
ph, ok := p.byValue[v]
|
|
if !ok {
|
|
p.count[s.Kind]++
|
|
ph = fmt.Sprintf("[%s_%d]", strings.ToUpper(s.Kind), p.count[s.Kind])
|
|
p.byValue[v] = ph
|
|
}
|
|
b.WriteString(text[last:s.Start])
|
|
b.WriteString(ph)
|
|
last = s.End
|
|
}
|
|
b.WriteString(text[last:])
|
|
return b.String()
|
|
}
|
|
|
|
// inverse is each placeholder and the value it replaced.
|
|
func (p *placeholders) inverse() map[string]string {
|
|
out := make(map[string]string, len(p.byValue))
|
|
for v, ph := range p.byValue {
|
|
out[ph] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
var _ Guard = (*Gate)(nil)
|
|
|
|
// Directives are the policy in force's demands of the gateway. Not in
|
|
// shadow mode, which changes nothing.
|
|
func (g *Gate) Directives(s Subject) Directives {
|
|
p, _ := g.Policy(s)
|
|
return Directives{NoStore: p.NoStore && p.Mode == "enforce"}
|
|
}
|
|
|
|
// Input judges a request body.
|
|
func (g *Gate) Input(ctx context.Context, s Subject, body []byte) (Verdict, error) {
|
|
start := g.now()
|
|
p, names := g.Policy(s)
|
|
if !p.active() {
|
|
return Verdict{}, nil
|
|
}
|
|
d, err := parseDoc(body)
|
|
if err != nil {
|
|
// Not this gate's to refuse: the engine says what is wrong with it.
|
|
return Verdict{}, nil
|
|
}
|
|
v := Verdict{Stage: Input, Policy: names, Shadow: p.Mode == "shadow"}
|
|
done := func(f finding) Verdict {
|
|
v.Action, v.Category, v.Rule = f.action, f.category, f.rule
|
|
if f.action == Block {
|
|
v.Message = fmt.Sprintf("this request was refused by the gateway's policy (%s)", refusedFor(f))
|
|
v.Body = nil
|
|
}
|
|
v.Took = g.now().Sub(start)
|
|
return v
|
|
}
|
|
|
|
var fs []finding
|
|
changed := false
|
|
// Tools first: a denied tool is stripped before anything else is judged.
|
|
if t := p.Tools; len(t.Allow) > 0 || len(t.Deny) > 0 {
|
|
for _, tl := range d.tools() {
|
|
if toolAllowed(t, tl.name) {
|
|
continue
|
|
}
|
|
if t.OnDenied == "block" {
|
|
fs = append(fs, finding{action: Block, category: "tool", rule: "tool:" + tl.name})
|
|
} else {
|
|
fs = append(fs, finding{action: Flag, category: "tool", rule: "tool:" + tl.name})
|
|
}
|
|
}
|
|
if t.OnDenied != "block" {
|
|
d.keepTools(func(n string) bool { return toolAllowed(t, n) })
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
var answer json.RawMessage
|
|
if p.ValidateStructured {
|
|
answer = d.responseSchema()
|
|
}
|
|
var offered []tool
|
|
if p.Tools.ValidateArguments {
|
|
offered = d.tools()
|
|
}
|
|
user := lastUser(segsOf(d))
|
|
var judgeUser string
|
|
if p.Judge != nil && p.Judge.Output != "" {
|
|
judgeUser = user
|
|
}
|
|
g.tools.put(s.RequestID, offered, answer, judgeUser, start)
|
|
if p.RequireCapabilities {
|
|
if len(d.tools()) > 0 && !s.Tools {
|
|
fs = append(fs, finding{action: Block, category: "capability", rule: "tools"})
|
|
}
|
|
if d.hasImages() && !s.Vision {
|
|
fs = append(fs, finding{action: Block, category: "capability", rule: "vision"})
|
|
}
|
|
}
|
|
|
|
segs := d.segments()
|
|
total := 0
|
|
ph := newPlaceholders()
|
|
type rewrite struct {
|
|
seg segment
|
|
text string
|
|
spans []detect.Span
|
|
}
|
|
var rewrites []rewrite
|
|
for _, sg := range segs {
|
|
total += len(sg.text)
|
|
f, red := g.judge(sg.text, p.Input)
|
|
fs = append(fs, f...)
|
|
if len(red) > 0 {
|
|
rewrites = append(rewrites, rewrite{sg, ph.rewrite(sg.text, red), red})
|
|
}
|
|
}
|
|
if p.Input.MaxChars > 0 && total > p.Input.MaxChars {
|
|
fs = append(fs, finding{action: Block, category: "size", rule: "max_chars"})
|
|
}
|
|
best, found := strictest(fs)
|
|
if found && best.action == Block {
|
|
return done(best), nil
|
|
}
|
|
// Outside services next, then the judge: what a pattern already stopped
|
|
// need not wait for either.
|
|
if user != "" {
|
|
// As the model will read it: what redact took out stays out of the
|
|
// outside service's sight too.
|
|
told := user
|
|
for _, rw := range rewrites {
|
|
if rw.seg.text != "" && strings.Contains(told, rw.seg.text) {
|
|
told = strings.ReplaceAll(told, rw.seg.text, rw.text)
|
|
}
|
|
}
|
|
sf, serr := g.askServices(ctx, p, true, told)
|
|
if serr != nil && !v.Shadow {
|
|
v.Took = g.now().Sub(start)
|
|
return v, serr
|
|
}
|
|
fs = append(fs, sf...)
|
|
if best, found = strictest(fs); found && best.action == Block {
|
|
return done(best), nil
|
|
}
|
|
}
|
|
// The judge last: what a pattern already stopped need not wait for it.
|
|
if jp := p.Judge; jp != nil && jp.Input != "" && user != "" {
|
|
res, err := g.judger.ask(ctx, jp, user, "")
|
|
if err != nil {
|
|
g.log.Warn("the judge gave no verdict on a request", "judge", jp.Name(), "err", err)
|
|
g.noJudger(jp, err)
|
|
}
|
|
if err != nil && p.Fail == "closed" && !v.Shadow {
|
|
v.Took = g.now().Sub(start)
|
|
return v, fmt.Errorf("the judge could not be asked: %w", err)
|
|
}
|
|
jf := judgeFinding(p, jp.Input, res, err)
|
|
if jf.failOpen {
|
|
g.m.failOpen.Inc()
|
|
}
|
|
fs = append(fs, jf)
|
|
if best, found = strictest(fs); found && best.action == Block {
|
|
return done(best), nil
|
|
}
|
|
}
|
|
if !v.Shadow {
|
|
for _, rw := range rewrites {
|
|
g.countRedactions(rw.spans)
|
|
rw.seg.set(rw.text)
|
|
changed = true
|
|
}
|
|
if p.Input.Restore && len(rewrites) > 0 {
|
|
v.Restore = ph.inverse()
|
|
}
|
|
if d.pin(p.SystemPrompt, p.DropClientSystem) {
|
|
changed = true
|
|
}
|
|
if d.capOutput(p.MaxOutputTokens) {
|
|
changed = true
|
|
}
|
|
if changed {
|
|
v.Body = d.encode()
|
|
}
|
|
}
|
|
if found {
|
|
return done(best), nil
|
|
}
|
|
v.Took = g.now().Sub(start)
|
|
return v, nil
|
|
}
|
|
|
|
// judgeFinding is what a judge's answer, or its failing to give one, comes
|
|
// to under a policy.
|
|
// SetJudgeAlarm says whom to tell when a judge gives no verdict: set before
|
|
// the gate serves.
|
|
func (g *Gate) SetJudgeAlarm(f func(model string, err error)) { g.alarm = f }
|
|
|
|
// noJudger records, against each judge a policy asks, that there was no way
|
|
// to ask any: a gate built without SetJudge. With one, each judge's failure
|
|
// is recorded as it is asked.
|
|
func (g *Gate) noJudger(jp *JudgePolicy, err error) {
|
|
if g.judger != nil {
|
|
return
|
|
}
|
|
for _, m := range jp.Judges() {
|
|
g.judgeFailed(m, err)
|
|
}
|
|
}
|
|
|
|
func (g *Gate) judgeFailed(model string, err error) {
|
|
g.errMu.Lock()
|
|
if g.lastErr == nil {
|
|
g.lastErr = map[string]JudgeError{}
|
|
}
|
|
g.lastErr[model] = JudgeError{Err: err.Error(), At: g.now()}
|
|
g.errMu.Unlock()
|
|
if g.alarm != nil {
|
|
g.alarm(model, err)
|
|
}
|
|
}
|
|
|
|
// JudgeError is a judge's last failure to give a verdict.
|
|
type JudgeError struct {
|
|
Err string
|
|
At time.Time
|
|
}
|
|
|
|
// JudgeErrors are each judge's last failure to give a verdict, by model.
|
|
func (g *Gate) JudgeErrors() map[string]JudgeError {
|
|
g.errMu.Lock()
|
|
defer g.errMu.Unlock()
|
|
return maps.Clone(g.lastErr)
|
|
}
|
|
|
|
func judgeFinding(p Policy, action string, res judgment, err error) finding {
|
|
if err != nil {
|
|
// Unanswered: closed stops it, open lets it through, said.
|
|
if p.Fail == "closed" {
|
|
return finding{action: Block, category: "judge", rule: "unavailable"}
|
|
}
|
|
return finding{action: Flag, category: "judge", rule: "unavailable", failOpen: true}
|
|
}
|
|
if ok, cat := p.Judge.counts(res); ok {
|
|
return finding{action: actions[action], category: "judge", rule: cat}
|
|
}
|
|
return finding{}
|
|
}
|
|
|
|
func segsOf(d *doc) []segment { return d.segments() }
|
|
|
|
func (g *Gate) countRedactions(spans []detect.Span) {
|
|
for _, s := range spans {
|
|
g.m.redactions.WithLabelValues(s.Kind).Inc()
|
|
}
|
|
}
|
|
|
|
// lastUser is the last thing the user said: what a judge reads.
|
|
func lastUser(segs []segment) string {
|
|
for i := len(segs) - 1; i >= 0; i-- {
|
|
if segs[i].role == "user" {
|
|
return segs[i].text
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func toolAllowed(t Tools, name string) bool {
|
|
for _, d := range t.Deny {
|
|
if d == name {
|
|
return false
|
|
}
|
|
}
|
|
if len(t.Allow) == 0 {
|
|
return true
|
|
}
|
|
for _, a := range t.Allow {
|
|
if a == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// refusedFor is what a refusal names: the category, and for a judge the
|
|
// hazard it saw — "judge: S6,Specialized Advice" — so whoever is refused
|
|
// can say why, and a judge refusing ordinary questions can be told apart.
|
|
// Never the text.
|
|
func refusedFor(f finding) string {
|
|
if f.category == "judge" && f.rule != "" && f.rule != "unavailable" {
|
|
return "judge: " + f.rule
|
|
}
|
|
return f.category
|
|
}
|