A guardrail policy can ask someone else's service
CI / test (push) Failing after 7m39s

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 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-25 16:49:56 +01:00
1 parent 81685ecd75
commit 783dc9e9f6
10 files changed
+428 -1

No files matched your search

+3 -1
View File
@@ -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".
+5
View File
@@ -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
+33
View File
@@ -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:<name>:<reason>`.
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
+7
View File
@@ -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`
+1
View File
@@ -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{
+13
View File
@@ -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, "")
+27
View File
@@ -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
+13
View File
@@ -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)
+226
View File
@@ -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": <text>}.
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
}
+100
View File
@@ -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)
}
}