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:
Ilker ManapandClaude Opus 5.5 committed 2026-10-04 15:15:38 +02:00
1 parent 7e18b8ab53
commit 02aac84ca5
11 files changed
+305 -3

No files matched your search

+1
View File
@@ -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)
}
+11
View File
@@ -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.
+7 -2
View File
@@ -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 }
+52
View File
@@ -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
})
}
+25
View File
@@ -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
View File
@@ -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)
+108
View File
@@ -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)
}))
}
+27
View File
@@ -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)
}
}
+3
View File
@@ -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
+46
View File
@@ -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}}
+3
View File
@@ -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>