Files
Ilker ManapandClaude Opus 5.5 b0273ffb5f Qwen3Guard is the judge, and judges on a GPU and on CPUs are asked in turn
Llama Guard 3 1B could not tell ordinary coding requests from harmful ones:
on the PoC it scored "continue" 0.51, "continue the code above" 0.89 and
"how do I kill all child processes in bash" 0.92 unsafe, against 0.97 to
1.00 for harmful requests, at Q4_K_M and Q8_0 alike, with its own template.
A site had "continue" refused. Qwen3Guard-Gen 0.6B gave the ordinary ones
0.15 at the most and the harmful ones 1.00, in 0.8 GB on CPUs.

- format qwen3guard: its prompt written out as its template writes it, for
  requests and answers, and its Safe / Unsafe / Controversial verdict read;
  controversial counts as the policy's judge.controversial says, safe by
  default.
- judge.models: judges asked in turn, the first that answers deciding; a
  failure is recorded against the judge that failed. guard judge use sets
  the order, replacing categories none of the judges names.
- known judges (Qwen3Guard 0.6B/4B/8B, Llama Guard 1B/8B): guard judge
  enable serves any of them, --gpu the 4B on the roomiest GPU class as a
  standard model; the leader fetches and serves one a policy names that the
  catalog lacks.
- server init installs qwen3guard-0.6b with the baseline's categories.
- a judge reads a long text by its start and end, which fit its context,
  rather than overflowing it and refusing the request under fail: closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:22:47 +02:00

554 lines
14 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()
}
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
}
// 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 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
}