The /admin/guard page, a Guardrails link, and the policies in force on the profile page
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7e18b8ab53
commit
02aac84ca5
11 files changed
+305
-3
No files matched your search
@@ -132,6 +132,7 @@ func newServerCmd() *cobra.Command {
|
||||
}
|
||||
gate.SetJudge(rt.Judge, rt.GuardFormat)
|
||||
rt.SetGuard(gate)
|
||||
rt.SetGuardLibrary(cfg.Guard)
|
||||
if err := rt.RegisterMetrics(gate); err != nil {
|
||||
return fmt.Errorf("guard metrics: %w", err)
|
||||
}
|
||||
|
||||
@@ -180,6 +180,17 @@ func (r *Router) Handler() http.Handler {
|
||||
pages.SetPersonalOff(r.personalOff)
|
||||
pages.SetLlamaCppVersion(r.llamaVersion)
|
||||
pages.SetPersonalDefaults(r.personalDefaults)
|
||||
if pol, ok := r.guard.(interface {
|
||||
Policy(guard.Subject) (guard.Policy, string)
|
||||
}); ok {
|
||||
pages.SetGuardPolicies(func(s guard.Subject) (string, bool) {
|
||||
p, names := pol.Policy(s)
|
||||
return names, p.NoStore && p.Mode == "enforce"
|
||||
})
|
||||
}
|
||||
if r.store != nil && len(r.guardLibrary.Policies) > 0 {
|
||||
pages.RegisterAdminGuard(mux, r.store, r.guardLibrary)
|
||||
}
|
||||
pages.Register(mux)
|
||||
r.registerChat(mux, pages)
|
||||
// An organization's own page, for the people who manage it.
|
||||
|
||||
@@ -89,8 +89,10 @@ type Router struct {
|
||||
// isLeader says whether this process leads; nil is always.
|
||||
isLeader atomic.Pointer[func() bool]
|
||||
acct *accounting.Accountant
|
||||
// guard judges requests and answers; nil judges nothing.
|
||||
guard guard.Guard
|
||||
// guard judges requests and answers; nil judges nothing. guardLibrary
|
||||
// is the policies it was built from, for the pages that show them.
|
||||
guard guard.Guard
|
||||
guardLibrary guard.Config
|
||||
|
||||
// threads holds conversations and memory notes. It is store when auth is on,
|
||||
// its own file when only the threads block is configured, and nil when
|
||||
@@ -156,6 +158,9 @@ func (r *Router) SetMirror(m web.MirrorAdmin) { r.mirror = m }
|
||||
// SetGuard has every request and answer judged by g.
|
||||
func (r *Router) SetGuard(g guard.Guard) { r.guard = g }
|
||||
|
||||
// SetGuardLibrary is the policies the guard was built from, for the pages.
|
||||
func (r *Router) SetGuardLibrary(c guard.Config) { r.guardLibrary = c }
|
||||
|
||||
// SetRegistry gives the mirror page somewhere to look models up.
|
||||
func (r *Router) SetRegistry(reg web.Registry) { r.registry = reg }
|
||||
|
||||
|
||||
@@ -37,3 +37,55 @@ func (s *DB) DetachGuard(ctx context.Context, a guard.Attachment) (bool, error)
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// GuardEvent is one request the guardrails acted on, by name only.
|
||||
type GuardEvent struct {
|
||||
At time.Time
|
||||
RequestID string
|
||||
KeyID string
|
||||
UserID string
|
||||
ProjectID string
|
||||
Model string
|
||||
Status int
|
||||
Action string
|
||||
Policy string
|
||||
Category string
|
||||
GuardMs int64
|
||||
}
|
||||
|
||||
// GuardEvents are the newest requests the guardrails did something to —
|
||||
// flagged, redacted, blocked, or would have in shadow — since a moment.
|
||||
func (s *DB) GuardEvents(ctx context.Context, since time.Time, limit int) ([]GuardEvent, error) {
|
||||
return queryAll(ctx, s.db, `SELECT ts, request_id, key_id, COALESCE(user_id, ''), project_id, model, status,
|
||||
guard_action, guard_policy, guard_category, guard_ms
|
||||
FROM usage WHERE ts >= ? AND guard_action <> '' ORDER BY ts DESC LIMIT ?`,
|
||||
[]any{millis(since), limit},
|
||||
func(rows *sql.Rows) (GuardEvent, error) {
|
||||
var e GuardEvent
|
||||
var ts int64
|
||||
err := rows.Scan(&ts, &e.RequestID, &e.KeyID, &e.UserID, &e.ProjectID, &e.Model, &e.Status,
|
||||
&e.Action, &e.Policy, &e.Category, &e.GuardMs)
|
||||
e.At = fromMillis(ts)
|
||||
return e, err
|
||||
})
|
||||
}
|
||||
|
||||
// GuardCount is how many requests one combination covers.
|
||||
type GuardCount struct {
|
||||
ProjectID, Action, Policy, Category string
|
||||
Count int64
|
||||
}
|
||||
|
||||
// GuardCounts totals what the guardrails did since a moment, by project,
|
||||
// action, policy and category.
|
||||
func (s *DB) GuardCounts(ctx context.Context, since time.Time) ([]GuardCount, error) {
|
||||
return queryAll(ctx, s.db, `SELECT project_id, guard_action, guard_policy, guard_category, COUNT(*)
|
||||
FROM usage WHERE ts >= ? AND guard_action <> ''
|
||||
GROUP BY project_id, guard_action, guard_policy, guard_category
|
||||
ORDER BY COUNT(*) DESC`, []any{millis(since)},
|
||||
func(rows *sql.Rows) (GuardCount, error) {
|
||||
var c GuardCount
|
||||
err := rows.Scan(&c.ProjectID, &c.Action, &c.Policy, &c.Category, &c.Count)
|
||||
return c, err
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||
)
|
||||
@@ -25,3 +26,27 @@ func TestAGuardPolicyIsAttachedOnceAndDetached(t *testing.T) {
|
||||
t.Error("detached twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatTheGuardrailsDidCanBeReadBack(t *testing.T) {
|
||||
db := capacityStore(t)
|
||||
ctx := t.Context()
|
||||
now := time.Now()
|
||||
rows := []Usage{
|
||||
{At: now, KeyID: "k", ProjectID: "p1", Model: "m", ServedModel: "m", Status: 400, RequestID: "r1",
|
||||
GuardAction: "blocked", GuardPolicy: "base", GuardCategory: "secret", GuardMs: 3},
|
||||
{At: now, KeyID: "k", ProjectID: "p1", Model: "m", ServedModel: "m", Status: 200, RequestID: "r2",
|
||||
GuardAction: "blocked", GuardPolicy: "base", GuardCategory: "secret"},
|
||||
{At: now, KeyID: "k", ProjectID: "p2", Model: "m", ServedModel: "m", Status: 200, RequestID: "r3"},
|
||||
}
|
||||
if err := db.WriteUsage(ctx, rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev, err := db.GuardEvents(ctx, now.Add(-time.Hour), 10)
|
||||
if err != nil || len(ev) != 2 || ev[0].Category != "secret" {
|
||||
t.Fatalf("%+v %v", ev, err)
|
||||
}
|
||||
c, err := db.GuardCounts(ctx, now.Add(-time.Hour))
|
||||
if err != nil || len(c) != 1 || c[0].Count != 2 || c[0].ProjectID != "p1" {
|
||||
t.Errorf("%+v %v", c, err)
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/clientip"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,11 @@ type AccountPages struct {
|
||||
hasMirror bool
|
||||
// hasQuotas is set when /admin/quotas is registered.
|
||||
hasQuotas bool
|
||||
// hasGuard is set when /admin/guard is registered.
|
||||
hasGuard bool
|
||||
// guardFor names the guardrail policies in force for someone, and
|
||||
// whether they keep nothing; nil when there are none.
|
||||
guardFor func(guard.Subject) (string, bool)
|
||||
// capacity sets capacity quotas, from the quotas page; nil leaves them off.
|
||||
// history is the metrics store, for what only it knows: the GPU time
|
||||
// each owner held. Nil without one.
|
||||
@@ -432,6 +438,9 @@ func (p *AccountPages) render(w http.ResponseWriter, r *http.Request,
|
||||
// personalValue is how a form names the person's own, project-less use.
|
||||
const personalValue = "personal"
|
||||
|
||||
// SetGuardPolicies has the profile say which guardrail policies apply where.
|
||||
func (p *AccountPages) SetGuardPolicies(f func(guard.Subject) (string, bool)) { p.guardFor = f }
|
||||
|
||||
// SetPersonalOff says personal keys are refused on this gateway.
|
||||
func (p *AccountPages) SetPersonalOff(off bool) { p.personalOff = off }
|
||||
|
||||
@@ -463,7 +472,19 @@ func (p *AccountPages) fillPlaces(ctx context.Context, user store.User, d *profi
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
d.LimitsBy = append(d.LimitsBy, contextLimits{Label: c.Label, Rows: limitRows(e),
|
||||
rows := limitRows(e)
|
||||
if p.guardFor != nil {
|
||||
names, keepNothing := p.guardFor(guard.Subject{UserID: user.ID, ProjectID: e.ProjectID,
|
||||
TeamID: e.TeamID, OrgID: e.OrgID})
|
||||
if names != "" {
|
||||
v := strings.ReplaceAll(names, ",", ", ")
|
||||
if keepNothing {
|
||||
v += " — nothing is kept: no conversations, notes or outside models"
|
||||
}
|
||||
rows = append(rows, limitRow{"Guardrails", v})
|
||||
}
|
||||
}
|
||||
d.LimitsBy = append(d.LimitsBy, contextLimits{Label: c.Label, Rows: rows,
|
||||
project: project, chain: e.Chain})
|
||||
}
|
||||
p.fillOwnUse(ctx, user, d)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// GuardAdminSource is what the guardrails page reads.
|
||||
type GuardAdminSource interface {
|
||||
GuardEvents(ctx context.Context, since time.Time, limit int) ([]store.GuardEvent, error)
|
||||
GuardCounts(ctx context.Context, since time.Time) ([]store.GuardCount, error)
|
||||
GuardAttachments(ctx context.Context) ([]guard.Attachment, error)
|
||||
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
|
||||
}
|
||||
|
||||
type guardPageData struct {
|
||||
Nav Nav
|
||||
Version string
|
||||
Window string
|
||||
Windows []string
|
||||
// Policies are the library, Default those applied to every request.
|
||||
Policies []string
|
||||
Default []string
|
||||
Attachments []guardAttachmentRow
|
||||
Counts []guardCountRow
|
||||
Events []guardEventRow
|
||||
Error string
|
||||
}
|
||||
|
||||
type guardAttachmentRow struct{ Scope, Who, Policy string }
|
||||
|
||||
type guardCountRow struct {
|
||||
Where, Action, Policy, Category string
|
||||
Count int64
|
||||
}
|
||||
|
||||
type guardEventRow struct {
|
||||
At, RequestID, Where, Model, Action, Policy, Category string
|
||||
Status int
|
||||
Ms int64
|
||||
}
|
||||
|
||||
var guardWindows = map[string]time.Duration{"24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour}
|
||||
|
||||
// RegisterAdminGuard mounts /admin/guard: what the guardrails did, where, by
|
||||
// which policy — names and counts, never text.
|
||||
func (p *AccountPages) RegisterAdminGuard(mux *http.ServeMux, s GuardAdminSource, library guard.Config) {
|
||||
p.hasGuard = true
|
||||
mux.HandleFunc("GET /admin/guard", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
ctx := r.Context()
|
||||
d := guardPageData{Nav: p.navFor(r, admin, "guard"), Version: p.version, Window: r.URL.Query().Get("window"),
|
||||
Windows: []string{"24h", "7d", "30d"}, Default: library.Default}
|
||||
since, ok := guardWindows[d.Window]
|
||||
if !ok {
|
||||
d.Window, since = "7d", guardWindows["7d"]
|
||||
}
|
||||
for name := range library.Policies {
|
||||
d.Policies = append(d.Policies, name)
|
||||
}
|
||||
slices.Sort(d.Policies)
|
||||
|
||||
where := map[string]string{"": "personal"}
|
||||
if projects, err := s.AllProjects(ctx); err == nil {
|
||||
for _, pp := range projects {
|
||||
where[pp.ID] = pp.Path
|
||||
}
|
||||
}
|
||||
name := func(id string) string {
|
||||
if n, ok := where[id]; ok {
|
||||
return n
|
||||
}
|
||||
return id
|
||||
}
|
||||
if att, err := s.GuardAttachments(ctx); err == nil {
|
||||
for _, a := range att {
|
||||
who := a.ScopeID
|
||||
if a.Scope == "project" {
|
||||
who = name(a.ScopeID)
|
||||
}
|
||||
d.Attachments = append(d.Attachments, guardAttachmentRow{Scope: a.Scope, Who: who, Policy: a.Policy})
|
||||
}
|
||||
}
|
||||
counts, err := s.GuardCounts(ctx, time.Now().Add(-since))
|
||||
if err != nil {
|
||||
d.Error = err.Error()
|
||||
}
|
||||
for _, c := range counts {
|
||||
d.Counts = append(d.Counts, guardCountRow{Where: name(c.ProjectID), Action: c.Action,
|
||||
Policy: c.Policy, Category: c.Category, Count: c.Count})
|
||||
}
|
||||
events, err := s.GuardEvents(ctx, time.Now().Add(-since), 100)
|
||||
if err != nil {
|
||||
d.Error = err.Error()
|
||||
}
|
||||
for _, e := range events {
|
||||
d.Events = append(d.Events, guardEventRow{At: e.At.UTC().Format("2006-01-02 15:04:05"),
|
||||
RequestID: e.RequestID, Where: name(e.ProjectID), Model: e.Model, Action: e.Action,
|
||||
Policy: e.Policy, Category: e.Category, Status: e.Status, Ms: e.GuardMs})
|
||||
}
|
||||
noStore(w)
|
||||
render(w, "admin_guard", d)
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheGuardrailsPageShowsNamesAndCounts(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
render(rec, "admin_guard", guardPageData{
|
||||
Nav: Nav{Admin: true, Guard: true, Current: "guard"}, Window: "7d", Windows: []string{"24h", "7d"},
|
||||
Policies: []string{"base", "finance"}, Default: []string{"base"},
|
||||
Attachments: []guardAttachmentRow{{Scope: "project", Who: "Acme/fin/payroll", Policy: "finance"}},
|
||||
Counts: []guardCountRow{{Where: "Acme/fin/payroll", Action: "blocked", Policy: "base,finance", Category: "iban", Count: 3}},
|
||||
Events: []guardEventRow{{At: "2026-10-04 10:00:00", RequestID: "req_1", Action: "blocked", Category: "iban", Status: 400}},
|
||||
})
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"Acme/fin/payroll", "base,finance", "req_1", `href="/admin/guard" aria-current="page"`, `href="?window=24h"`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("no %q", want)
|
||||
}
|
||||
}
|
||||
if rec.Code != 200 {
|
||||
t.Errorf("status %d: %s", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ type Nav struct {
|
||||
// Mirror and Quotas are whether those pages were mounted at all; usage is
|
||||
// mounted with quotas.
|
||||
Mirror, Quotas bool
|
||||
// Guard is whether the guardrails page was mounted.
|
||||
Guard bool
|
||||
// CSRF signs the sign-out form.
|
||||
CSRF string
|
||||
// Org offers the organization page: to an administrator, and to anyone
|
||||
@@ -38,6 +40,7 @@ func (p *AccountPages) navFor(r *http.Request, user store.User, current string)
|
||||
Admin: user.IsAdmin,
|
||||
Mirror: p.hasMirror,
|
||||
Quotas: p.hasQuotas,
|
||||
Guard: p.hasGuard,
|
||||
CSRF: csrfFor(r),
|
||||
}
|
||||
// Only when there is an organization to show: a link that leads to "there
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "admin_guard"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Guardrails · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
<h1>Guardrails</h1>
|
||||
<p class="sub">What the policies did, where, and by which rule — names and counts, never what was written.</p>
|
||||
{{template "nav" .Nav}}
|
||||
</header>
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
|
||||
<h2>Policies</h2>
|
||||
{{if .Policies}}
|
||||
<p>Defined in <code>server.yaml</code>: {{range $i, $p := .Policies}}{{if $i}}, {{end}}<code>{{$p}}</code>{{end}}.
|
||||
Applied to every request: {{if .Default}}{{range $i, $p := .Default}}{{if $i}}, {{end}}<code>{{$p}}</code>{{end}}{{else}}none{{end}}.</p>
|
||||
{{if .Attachments}}
|
||||
<table class="rows">
|
||||
<tr><th>Scope</th><th>Who</th><th>Policy</th></tr>
|
||||
{{range .Attachments}}<tr><td>{{.Scope}}</td><td>{{.Who}}</td><td><code>{{.Policy}}</code></td></tr>{{end}}
|
||||
</table>
|
||||
{{else}}<p class="sub">Attached nowhere else. <code>llmgw ctl guard attach project:ORG/TEAM/PROJECT POLICY</code> attaches one.</p>{{end}}
|
||||
{{else}}
|
||||
<p>No policies: add a <code>guard:</code> section to <code>server.yaml</code> (docs/guardrails.md).</p>
|
||||
{{end}}
|
||||
|
||||
<h2>What they did</h2>
|
||||
<p class="sub">Over the last
|
||||
{{range .Windows}}{{if eq . $.Window}}<strong>{{.}}</strong>{{else}}<a href="?window={{.}}">{{.}}</a>{{end}} {{end}}</p>
|
||||
{{if .Counts}}
|
||||
<table class="rows">
|
||||
<tr><th>Where</th><th>Action</th><th>Policy</th><th>Category</th><th class="num">Requests</th></tr>
|
||||
{{range .Counts}}<tr><td>{{.Where}}</td><td>{{.Action}}</td><td><code>{{.Policy}}</code></td><td>{{.Category}}</td><td class="num">{{.Count}}</td></tr>{{end}}
|
||||
</table>
|
||||
{{else}}<p class="sub">Nothing flagged, redacted or blocked.</p>{{end}}
|
||||
|
||||
{{if .Events}}
|
||||
<h2>Recent</h2>
|
||||
<table class="rows">
|
||||
<tr><th>When (UTC)</th><th>Request</th><th>Where</th><th>Model</th><th>Action</th><th>Policy</th><th>Category</th><th class="num">Status</th><th class="num">ms</th></tr>
|
||||
{{range .Events}}<tr><td>{{.At}}</td><td><code>{{.RequestID}}</code></td><td>{{.Where}}</td><td>{{.Model}}</td>
|
||||
<td>{{.Action}}</td><td><code>{{.Policy}}</code></td><td>{{.Category}}</td><td class="num">{{.Status}}</td><td class="num">{{.Ms}}</td></tr>{{end}}
|
||||
</table>
|
||||
<p class="sub"><code>llmgw ctl request ID</code> says what else a request went through.</p>
|
||||
{{end}}
|
||||
</div></body></html>{{end}}
|
||||
@@ -13,6 +13,9 @@
|
||||
{{- if .Mirror}}
|
||||
<a href="/admin/mirror"{{if eq .Current "mirror"}} aria-current="page"{{end}}>Mirror</a>
|
||||
{{- end}}
|
||||
{{- if .Guard}}
|
||||
<a href="/admin/guard"{{if eq .Current "guard"}} aria-current="page"{{end}}>Guardrails</a>
|
||||
{{- end}}
|
||||
{{- /* An organization is a panel of the people page for an administrator;
|
||||
/org is the managers' view, and leads an administrator there. */}}
|
||||
<span class="menu-gap" aria-hidden="true"></span>
|
||||
|
||||
Reference in new issue
Block a user