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:
1 parent
81685ecd75
commit
783dc9e9f6
10 files changed
+428
-1
No files matched your search
+3
-1
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user