From 783dc9e9f6102206775d76758b7014f50b80ecac Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sun, 25 Oct 2026 16:49:56 +0100 Subject: [PATCH] A guardrail policy can ask someone else's service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A site may be required to use a particular guardrail: Azure Content Safety, Lakera, NeMo, a classifier of its own. A policy's services asks one over HTTP — a body template given the text, header values from files read each call, dotted paths to where the answer says unsafe and why — on input, output or both, to block or flag. On input it reads the latest user turn before the judge; on output the whole answer once at its end. A service that does not answer is what the policy's fail says. It is told the text only, never who sent it. Co-Authored-By: Claude Opus 5.5 --- BACKLOG.md | 4 +- CHANGELOG.md | 5 + docs/guardrails.md | 33 +++++ docs/reference.md | 7 + internal/docgen/reference_gen.go | 1 + internal/guard/gate.go | 13 ++ internal/guard/output.go | 27 ++++ internal/guard/policy.go | 13 ++ internal/guard/service.go | 226 +++++++++++++++++++++++++++++++ internal/guard/service_test.go | 100 ++++++++++++++ 10 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 internal/guard/service.go create mode 100644 internal/guard/service_test.go diff --git a/BACKLOG.md b/BACKLOG.md index cd042c5..035e7cc 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -1715,7 +1715,9 @@ answers "Dear [PERSON_1]" leaves it so. Kong's sanitizer restores. policy reaches the model as placeholders and the client reads the real name and address back. -### L3. A guardrail that is someone else's service (S) +### L3. A guardrail that is someone else's service (S) — done 2026-10-25 +`guard.Service` (`internal/guard/service.go`), policy `services`, asked +before the judge on input and once on the whole answer. The original item follows. Customers may be required to use one: Azure Content Safety, Lakera, NeMo Guardrails, an in-house classifier. Kong's AI Custom Guardrail is a templated HTTP call with an expression for "block". diff --git a/CHANGELOG.md b/CHANGELOG.md index c20c27a..744fcd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ APIs change between minors without a deprecation period. ### Added +- **A guardrail that is someone else's service.** A policy's `services` + asks Azure Content Safety, Lakera, NeMo or a company's own classifier over + HTTP: a body template, keys from files, dotted paths to where the answer + says unsafe and why, on input, output or both, block or flag, and the + policy's `fail` when it does not answer. - **Redacted values put back in the answer.** `restore: true` on a guardrail policy's input: the model reads `[EMAIL_1]`, the client reads its own address in the reply, streamed or not. Only the request's own values, held diff --git a/docs/guardrails.md b/docs/guardrails.md index 8225311..77adfbd 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -273,6 +273,39 @@ themselves. context rather than overflowing it and going unjudged. - **Cache:** the same text is not asked about twice within five minutes. +## An outside service + +A site required to use a particular guardrail — Azure Content Safety, +Lakera Guard, NeMo Guardrails, a classifier of its own — names it in a +policy's `services`. It is asked over HTTP beside the patterns and before +the judge; its "unsafe" blocks or flags like theirs, and a service that does +not answer is what the policy's `fail` says. + +```yaml +policies: + strict: + fail: closed + services: + - name: lakera + url: https://api.lakera.ai/v2/guard + header_files: {Authorization: /etc/llmgw/secrets/lakera} # "Bearer …", read each call + body: '{"messages": [{"role": "user", "content": {{json .Text}}}]}' + blocked: flagged # where the answer says unsafe + reason: breakdown.0.detector_type + on: both # input, output or both + action: block # or flag + timeout: 2s +``` + +`body` is a template given `.Text` (`{{json .Text}}` writes it as a JSON +string; empty sends `{"input": …}`). `blocked` and `reason` are dotted paths +into the JSON answer, numbers for list items; true, a number above zero, a +non-empty list or a string other than "false" is unsafe. On input the +service reads the latest user turn; on output the whole answer, once, at its +end — a streamed answer released a sentence at a time is then stopped or +flagged as the judge's is. The rule recorded is `service::`. +The service is told the text only, never who sent it. + ## Where a policy applies `guard.default` applies everywhere. Others are attached to a part of the diff --git a/docs/reference.md b/docs/reference.md index ee440c1..e9d5469 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -257,6 +257,7 @@ rather than repeated. | `require_capabilities` | `bool` | [Policy](#policy) | | `validate_structured` | `bool` | [Policy](#policy) | | `judge` | `*JudgePolicy` | [Policy](#policy) | +| `services` | `[]Service` | [Policy](#policy) | | `stream` | `string` | [Policy](#policy) | | `enabled` | `bool` | [Prefix](#prefix) | | `max_nodes` | `int` | [Prefix](#prefix) | @@ -2296,6 +2297,12 @@ Judge has a judge model on the cluster read the text too. Takes a [JudgePolicy](#judgepolicy) block. +### `services` + +`[]Service` + +Services are outside guardrails asked over HTTP, beside the judge. + ### `stream` `string` diff --git a/internal/docgen/reference_gen.go b/internal/docgen/reference_gen.go index 9f97c16..7c29d3d 100644 --- a/internal/docgen/reference_gen.go +++ b/internal/docgen/reference_gen.go @@ -329,6 +329,7 @@ var Reference = []Block{ {Key: "require_capabilities", Type: "bool", Doc: "RequireCapabilities refuses a request offering tools, or sending\nimages, to a model whose catalog entry does not say it takes them.", Nested: ""}, {Key: "validate_structured", Type: "bool", Doc: "ValidateStructured checks an answer against the JSON schema the\nrequest asked for in response_format: one that does not fit is\nstopped — or, streamed a sentence at a time, flagged, having been\nsent already.", Nested: ""}, {Key: "judge", Type: "*JudgePolicy", Doc: "Judge has a judge model on the cluster read the text too.", Nested: "guard.JudgePolicy"}, + {Key: "services", Type: "[]Service", Doc: "Services are outside guardrails asked over HTTP, beside the judge.", Nested: ""}, {Key: "stream", Type: "string", Doc: "Stream: sentence scans a streamed answer a sentence at a time,\nreleasing each once it passes; buffer_all holds the whole answer\nuntil it is judged. Empty is sentence.", Nested: ""}, }}, {Package: "config", Name: "Prefix", Title: "Prefix", Doc: "", Fields: []Field{ diff --git a/internal/guard/gate.go b/internal/guard/gate.go index 70c8333..b6b03d5 100644 --- a/internal/guard/gate.go +++ b/internal/guard/gate.go @@ -415,6 +415,19 @@ func (g *Gate) Input(ctx context.Context, s Subject, body []byte) (Verdict, erro 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 != "" { + sf, serr := g.askServices(ctx, p, true, user) + 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, "") diff --git a/internal/guard/output.go b/internal/guard/output.go index 4fca0bc..4103850 100644 --- a/internal/guard/output.go +++ b/internal/guard/output.go @@ -3,6 +3,7 @@ package guard import ( "context" "encoding/json" + "slices" "strings" "sync" "time" @@ -55,6 +56,7 @@ func outputWatched(p Policy) bool { o, t := p.Output, p.Tools return len(o.PII) > 0 || o.Injection != "" || len(o.Deny) > 0 || o.MaxChars > 0 || p.ValidateStructured || p.Judge != nil && p.Judge.Output != "" || + slices.ContainsFunc(p.Services, Service.onOutput) || len(t.Allow) > 0 || len(t.Deny) > 0 || t.MaxCallsPerTurn > 0 || t.ValidateArguments } @@ -82,6 +84,7 @@ type outFilter struct { full strings.Builder // the whole answer, when it must fit a schema user string // the request's last user turn, for the judge sofar strings.Builder // the answer so far, for the judge + served strings.Builder // the answer so far, for outside services, asked once at its end judged int // how much of it the judge has read v Verdict took time.Duration @@ -203,6 +206,21 @@ func (f *outFilter) askJudge(text string, final bool) finding { return f.record([]finding{jf}) } +// askServices has the outside services read the whole answer, once, at its +// end. One that cannot be asked under fail: closed stops it. +func (f *outFilter) askServices(text string) finding { + if !slices.ContainsFunc(f.p.Services, Service.onOutput) || text == "" { + return finding{} + } + start := time.Now() + fs, err := f.g.askServices(f.ctx, f.p, false, text) + f.took += time.Since(start) + if err != nil { + fs = append(fs, finding{action: Block, category: "service", rule: "service:unavailable"}) + } + return f.record(fs) +} + // judgeStructured checks a whole answer against the schema the request // asked it to fit. func (f *outFilter) judgeStructured(text string, enforce bool) finding { @@ -259,6 +277,9 @@ func (f *outFilter) Buffered(body []byte) []byte { if f.askJudge(t, true).action == Block { blocked = true } + if f.askServices(t).action == Block { + blocked = true + } if out != t { msg["content"] = rawString(out) ch["message"], _ = json.Marshal(msg) @@ -422,6 +443,12 @@ func (f *outFilter) flush(final bool) (bool, map[int]string, bool) { if i == 0 && f.askJudge(head, final).action == Block { blocked = true } + if i == 0 { + f.served.WriteString(head) + if final && f.askServices(f.served.String()).action == Block { + blocked = true + } + } if head != "" { fd, r := f.judgeText(head) blocked = blocked || fd.action == Block diff --git a/internal/guard/policy.go b/internal/guard/policy.go index 06377dd..7046255 100644 --- a/internal/guard/policy.go +++ b/internal/guard/policy.go @@ -69,6 +69,8 @@ type Policy struct { ValidateStructured bool `koanf:"validate_structured"` // Judge has a judge model on the cluster read the text too. Judge *JudgePolicy `koanf:"judge"` + // Services are outside guardrails asked over HTTP, beside the judge. + Services []Service `koanf:"services"` // Stream: sentence scans a streamed answer a sentence at a time, // releasing each once it passes; buffer_all holds the whole answer // until it is judged. Empty is sentence. @@ -154,6 +156,11 @@ func (p Policy) validate() error { return err } } + for _, sv := range p.Services { + if err := sv.validate(); err != nil { + return err + } + } switch p.Tools.OnDenied { case "", "strip", "block": default: @@ -213,6 +220,12 @@ func Merge(names []string, ps []Policy) Policy { out.RequireCapabilities = out.RequireCapabilities || p.RequireCapabilities out.ValidateStructured = out.ValidateStructured || p.ValidateStructured out.Judge = mergeJudge(out.Judge, p.Judge) + // Every service any policy asks, each once. + for _, sv := range p.Services { + if !slices.ContainsFunc(out.Services, func(e Service) bool { return e.Name == sv.Name }) { + out.Services = append(out.Services, sv) + } + } out.MaxOutputTokens = minSet(out.MaxOutputTokens, p.MaxOutputTokens) if p.SystemPrompt != "" { prompts = append(prompts, p.SystemPrompt) diff --git a/internal/guard/service.go b/internal/guard/service.go new file mode 100644 index 0000000..7985bec --- /dev/null +++ b/internal/guard/service.go @@ -0,0 +1,226 @@ +package guard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "text/template" + "time" +) + +// A guardrail that is someone else's service: Azure Content Safety, Lakera, +// NeMo Guardrails, a company's own classifier. A site may be required to use +// one; this asks it beside the patterns and the judge, and its answer counts +// like theirs. Told only the text, never who sent it, and nothing of the +// call is kept but its verdict. + +// Service is one outside guardrail, asked over HTTP. +type Service struct { + // Name says which service decided, in the verdict's rule. + Name string `koanf:"name"` + // URL is POSTed the body. + URL string `koanf:"url"` + // HeaderFiles are headers whose values are read from files each time, + // so a rotated key needs no restart: {Authorization: /etc/…/lakera}. + HeaderFiles map[string]string `koanf:"header_files"` + // Body is the request, a template given .Text; {{json .Text}} writes it + // as a JSON string. Empty is {"input": }. + Body string `koanf:"body"` + // Blocked is where in the answer the service says the text is unsafe: + // a dotted path, numbers for list items (results.0.flagged). True, a + // number above zero, a non-empty list or string other than "false" is + // unsafe. + Blocked string `koanf:"blocked"` + // Reason is where it says why, for the verdict; optional. + Reason string `koanf:"reason"` + // On: input, output or both. Empty is input. + On string `koanf:"on"` + // Action on unsafe: block or flag. Empty is block. + Action string `koanf:"action"` + // Timeout bounds one call. Empty is 3s; past it the policy's fail says. + Timeout time.Duration `koanf:"timeout"` +} + +func (s Service) validate() error { + if s.Name == "" { + return fmt.Errorf("services: one has no name") + } + u, err := url.Parse(s.URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("services.%s: url %q is not an http(s) address", s.Name, s.URL) + } + if s.Blocked == "" { + return fmt.Errorf("services.%s: blocked says where the answer says unsafe, and is empty", s.Name) + } + switch s.On { + case "", "input", "output", "both": + default: + return fmt.Errorf("services.%s: on is %q: input, output or both", s.Name, s.On) + } + switch s.Action { + case "", "block", "flag": + default: + return fmt.Errorf("services.%s: action is %q: block or flag", s.Name, s.Action) + } + if _, err := s.template(); err != nil { + return fmt.Errorf("services.%s: body: %w", s.Name, err) + } + return nil +} + +func (s Service) onInput() bool { return s.On == "" || s.On == "input" || s.On == "both" } +func (s Service) onOutput() bool { return s.On == "output" || s.On == "both" } + +func (s Service) action() Action { + if s.Action == "flag" { + return Flag + } + return Block +} + +func (s Service) template() (*template.Template, error) { + body := s.Body + if body == "" { + body = `{"input": {{json .Text}}}` + } + return template.New(s.Name).Funcs(template.FuncMap{"json": func(v any) (string, error) { + b, err := json.Marshal(v) + return string(b), err + }}).Parse(body) +} + +// serviceClient asks every service; each call is bounded by its own timeout. +var serviceClient = &http.Client{} + +// ask sends the service the text and says whether it found it unsafe, and why. +func (s Service) ask(ctx context.Context, text string) (unsafe bool, reason string, err error) { + t, err := s.template() + if err != nil { + return false, "", err + } + var body bytes.Buffer + if err := t.Execute(&body, struct{ Text string }{text}); err != nil { + return false, "", err + } + timeout := s.Timeout + if timeout <= 0 { + timeout = 3 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.URL, &body) + if err != nil { + return false, "", err + } + req.Header.Set("Content-Type", "application/json") + for h, file := range s.HeaderFiles { + v, err := os.ReadFile(file) + if err != nil { + return false, "", fmt.Errorf("header %s: %w", h, err) + } + req.Header.Set(h, strings.TrimSpace(string(v))) + } + resp, err := serviceClient.Do(req) + if err != nil { + return false, "", err + } + defer resp.Body.Close() + b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return false, "", err + } + if resp.StatusCode/100 != 2 { + return false, "", fmt.Errorf("answered %d", resp.StatusCode) + } + var doc any + if err := json.Unmarshal(b, &doc); err != nil { + return false, "", fmt.Errorf("answer is not JSON: %w", err) + } + v, ok := at(doc, s.Blocked) + if !ok { + return false, "", fmt.Errorf("the answer has no %s", s.Blocked) + } + if s.Reason != "" { + if r, ok := at(doc, s.Reason); ok { + reason = fmt.Sprint(r) + } + } + return truthy(v), reason, nil +} + +// at is the value at a dotted path in decoded JSON. +func at(doc any, path string) (any, bool) { + cur := doc + for _, k := range strings.Split(path, ".") { + switch c := cur.(type) { + case map[string]any: + v, ok := c[k] + if !ok { + return nil, false + } + cur = v + case []any: + i, err := strconv.Atoi(k) + if err != nil || i < 0 || i >= len(c) { + return nil, false + } + cur = c[i] + default: + return nil, false + } + } + return cur, true +} + +func truthy(v any) bool { + switch x := v.(type) { + case bool: + return x + case float64: + return x > 0 + case string: + return x != "" && !strings.EqualFold(x, "false") && x != "0" + case []any: + return len(x) > 0 + case map[string]any: + return len(x) > 0 + } + return false +} + +// askServices asks each service of a direction, and says what the policy +// makes of their answers: a finding per service that found the text unsafe, +// or could not be asked under fail: closed. failed is true when a service +// gave no answer, for the caller to refuse under fail: closed. +func (g *Gate) askServices(ctx context.Context, p Policy, input bool, text string) (fs []finding, failed error) { + for _, s := range p.Services { + if input && !s.onInput() || !input && !s.onOutput() || text == "" { + continue + } + unsafe, reason, err := s.ask(ctx, text) + if err != nil { + g.log.Warn("a guardrail service gave no verdict", "service", s.Name, "err", err) + if p.Fail == "closed" { + failed = fmt.Errorf("the guardrail service %s could not be asked: %w", s.Name, err) + continue + } + g.m.failOpen.Inc() + continue + } + if unsafe { + rule := "service:" + s.Name + if reason != "" { + rule += ":" + reason + } + fs = append(fs, finding{action: s.action(), category: "service", rule: rule}) + } + } + return fs, failed +} diff --git a/internal/guard/service_test.go b/internal/guard/service_test.go new file mode 100644 index 0000000..8e23c69 --- /dev/null +++ b/internal/guard/service_test.go @@ -0,0 +1,100 @@ +package guard + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// classifier is a stand-in for Lakera or Azure Content Safety: it finds +// "evil" unsafe, and refuses a call without its key. +func classifier(t *testing.T) (*httptest.Server, string) { + key := filepath.Join(t.TempDir(), "key") + if err := os.WriteFile(key, []byte("Bearer k1\n"), 0o600); err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer k1" { + http.Error(w, "no", http.StatusUnauthorized) + return + } + var in struct{ Input string } + _ = json.NewDecoder(r.Body).Decode(&in) + flagged := strings.Contains(in.Input, "evil") + _ = json.NewEncoder(w).Encode(map[string]any{"results": []any{map[string]any{"flagged": flagged, "category": "injection"}}}) + })) + t.Cleanup(srv.Close) + return srv, key +} + +func svcPolicy(url, key, on, action, fail string) Config { + return lib(map[string]Policy{"svc": {Fail: fail, Services: []Service{{ + Name: "cls", URL: url, HeaderFiles: map[string]string{"Authorization": key}, + Blocked: "results.0.flagged", Reason: "results.0.category", On: on, Action: action, + }}}}, "svc") +} + +func TestAnOutsideServiceBlocksOrFlagsWhatItFindsUnsafe(t *testing.T) { + srv, key := classifier(t) + ctx := context.Background() + g := gate(t, svcPolicy(srv.URL, key, "", "", "")) + v, err := g.Input(ctx, Subject{}, chat("user", "something evil")) + if err != nil || v.Action != Block || v.Rule != "service:cls:injection" { + t.Errorf("unsafe: %+v %v", v, err) + } + if v, _ := g.Input(ctx, Subject{}, chat("user", "hello")); v.Action != Allow { + t.Errorf("safe: %+v", v) + } + g = gate(t, svcPolicy(srv.URL, key, "input", "flag", "")) + if v, _ := g.Input(ctx, Subject{}, chat("user", "evil")); v.Action != Flag { + t.Errorf("flag: %+v", v) + } +} + +func TestAServiceThatCannotBeAskedIsWhatFailSays(t *testing.T) { + srv, _ := classifier(t) + ctx := context.Background() + // The wrong key: the service answers 401. + wrong := filepath.Join(t.TempDir(), "wrong") + _ = os.WriteFile(wrong, []byte("Bearer nope"), 0o600) + if _, err := gate(t, svcPolicy(srv.URL, wrong, "", "", "closed")).Input(ctx, Subject{}, chat("user", "hi")); err == nil { + t.Error("fail: closed served a request no service could check") + } + if v, err := gate(t, svcPolicy(srv.URL, wrong, "", "", "open")).Input(ctx, Subject{}, chat("user", "hi")); err != nil || v.Action != Allow { + t.Errorf("fail: open: %+v %v", v, err) + } +} + +func TestAnOutsideServiceReadsTheWholeAnswer(t *testing.T) { + srv, key := classifier(t) + g := gate(t, svcPolicy(srv.URL, key, "output", "", "")) + f := g.Output(context.Background(), Subject{}) + if f == nil { + t.Fatal("no output filter for an output service") + } + out := f.Buffered([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"an evil plan"},"finish_reason":"stop"}]}`)) + if !strings.Contains(string(out), "content_filter") || strings.Contains(string(out), "evil") || f.Verdict().Action != Block { + t.Errorf("%s %+v", out, f.Verdict()) + } +} + +func TestAServiceIsCheckedWhenThePolicyIsRead(t *testing.T) { + for _, s := range []Service{ + {Name: "a", URL: "ftp://x", Blocked: "x"}, + {Name: "b", URL: "https://x"}, + {Name: "c", URL: "https://x", Blocked: "x", On: "sideways"}, + {Name: "d", URL: "https://x", Blocked: "x", Body: "{{"}, + } { + if err := (Policy{Services: []Service{s}}).validate(); err == nil { + t.Errorf("%s passed", s.Name) + } + } + if v, ok := at(map[string]any{"a": []any{map[string]any{"b": 2.0}}}, "a.0.b"); !ok || !truthy(v) { + t.Errorf("at: %v %v", v, ok) + } +}