guard.enabled (unset is on) makes every policy check nothing while false, live: llmgw ctl guard off/on set it in the cluster's settings, and ctl status, ctl guard list and the guardrails page say when they are off. A refusal by a judge names the hazard it saw - judge: S6,Specialized Advice - and the verdict is kept with the request in a new usage column, guard_rule, shown by ctl request, the request log and the guardrails page. llmgw ctl guard judge ask has a judge read a text, past the cache, and shows its raw answer and how it is read; the endpoint is kept out of the audit so no text is recorded. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
85 lines
2.8 KiB
Go
85 lines
2.8 KiB
Go
package audit
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func TestNothingSecretIsRecorded(t *testing.T) {
|
|
got := Redact(FormFields(url.Values{
|
|
"current": {"old-pw"}, "new": {"new-pw"}, "password": {"pw"}, "csrf": {"tok"},
|
|
"name": {"ok-name"}, "note": {"sk-proj-abcdefgh"},
|
|
}))
|
|
for _, secret := range []string{"old-pw", "new-pw", `"pw"`, "tok", "sk-proj"} {
|
|
if strings.Contains(got, secret) {
|
|
t.Errorf("%s recorded: %s", secret, got)
|
|
}
|
|
}
|
|
if !strings.Contains(got, "ok-name") {
|
|
t.Errorf("an ordinary field was dropped: %s", got)
|
|
}
|
|
nested := Redact(map[string]any{"external": map[string]any{"api_key": "x1", "url": "https://p"}})
|
|
if strings.Contains(nested, "x1") || !strings.Contains(nested, "https://p") {
|
|
t.Errorf("nested: %s", nested)
|
|
}
|
|
}
|
|
|
|
type memSink struct{ got []store.AuditEvent }
|
|
|
|
func (m *memSink) WriteAudit(_ context.Context, e store.AuditEvent) error {
|
|
m.got = append(m.got, e)
|
|
return nil
|
|
}
|
|
|
|
func TestEveryChangeToTheAdminAPIIsRecorded(t *testing.T) {
|
|
sink := &memSink{}
|
|
h := API(sink, func(*http.Request) string { return "ilker" }, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusCreated)
|
|
}))
|
|
for _, req := range []*http.Request{
|
|
httptest.NewRequest("GET", "/api/v1/models", nil),
|
|
httptest.NewRequest("POST", "/api/v1/models", strings.NewReader(`{"name":"m1","external":{"api_key":"sk-1"}}`)),
|
|
httptest.NewRequest("DELETE", "/api/v1/nodes/n1", nil),
|
|
} {
|
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
|
}
|
|
if len(sink.got) != 2 {
|
|
t.Fatalf("recorded %d: %+v", len(sink.got), sink.got)
|
|
}
|
|
e := sink.got[0]
|
|
if e.Actor != "ilker" || e.Action != "POST /api/v1/models" || e.Target != "m1" || e.Status != 201 ||
|
|
strings.Contains(e.Detail, "sk-1") || e.Source != "api" {
|
|
t.Errorf("%+v", e)
|
|
}
|
|
}
|
|
|
|
func TestABodyLargerThanWhatIsRecordedReachesTheHandlerWhole(t *testing.T) {
|
|
big := `{"content":"` + strings.Repeat("x", 200<<10) + `"}`
|
|
var got int
|
|
h := API(&memSink{}, func(*http.Request) string { return "ilker" }, nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
got = len(b)
|
|
}))
|
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/v1/models/m/eval", strings.NewReader(big)))
|
|
if got != len(big) {
|
|
t.Errorf("the handler was given %d of %d bytes", got, len(big))
|
|
}
|
|
}
|
|
|
|
func TestAskingAJudgeKeepsNoText(t *testing.T) {
|
|
sink := &memSink{}
|
|
h := API(sink, func(*http.Request) string { return "ilker" }, nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/v1/guard/judges/ask",
|
|
strings.NewReader(`{"text":"something a user wrote"}`)))
|
|
if len(sink.got) != 0 {
|
|
t.Errorf("a judge's question was recorded: %+v", sink.got)
|
|
}
|
|
}
|