diff --git a/internal/cli/server.go b/internal/cli/server.go index fb9b7369..2c2421c5 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -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) } diff --git a/internal/router/handlers.go b/internal/router/handlers.go index 68d34c70..8cf302f9 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -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. diff --git a/internal/router/router.go b/internal/router/router.go index 122c7c42..7ed0e116 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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 } diff --git a/internal/store/guard.go b/internal/store/guard.go index 342aaa41..2aeed339 100644 --- a/internal/store/guard.go +++ b/internal/store/guard.go @@ -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 + }) +} diff --git a/internal/store/guard_test.go b/internal/store/guard_test.go index e0ded47c..5c3166dc 100644 --- a/internal/store/guard_test.go +++ b/internal/store/guard_test.go @@ -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) + } +} diff --git a/internal/web/account.go b/internal/web/account.go index 0f7a3f3f..229a8594 100644 --- a/internal/web/account.go +++ b/internal/web/account.go @@ -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) diff --git a/internal/web/admin_guard.go b/internal/web/admin_guard.go new file mode 100644 index 00000000..d9272e7f --- /dev/null +++ b/internal/web/admin_guard.go @@ -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) + })) +} diff --git a/internal/web/admin_guard_test.go b/internal/web/admin_guard_test.go new file mode 100644 index 00000000..4c4036a4 --- /dev/null +++ b/internal/web/admin_guard_test.go @@ -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) + } +} diff --git a/internal/web/nav.go b/internal/web/nav.go index b5ac48cc..92cb4ce5 100644 --- a/internal/web/nav.go +++ b/internal/web/nav.go @@ -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 diff --git a/internal/web/templates/admin_guard.html b/internal/web/templates/admin_guard.html new file mode 100644 index 00000000..fb6c051d --- /dev/null +++ b/internal/web/templates/admin_guard.html @@ -0,0 +1,46 @@ +{{define "admin_guard"}} + + +Guardrails · llmgw +
+
+

Guardrails

+

What the policies did, where, and by which rule — names and counts, never what was written.

+ {{template "nav" .Nav}} +
+{{if .Error}}{{end}} + +

Policies

+{{if .Policies}} +

Defined in server.yaml: {{range $i, $p := .Policies}}{{if $i}}, {{end}}{{$p}}{{end}}. +Applied to every request: {{if .Default}}{{range $i, $p := .Default}}{{if $i}}, {{end}}{{$p}}{{end}}{{else}}none{{end}}.

+{{if .Attachments}} + + +{{range .Attachments}}{{end}} +
ScopeWhoPolicy
{{.Scope}}{{.Who}}{{.Policy}}
+{{else}}

Attached nowhere else. llmgw ctl guard attach project:ORG/TEAM/PROJECT POLICY attaches one.

{{end}} +{{else}} +

No policies: add a guard: section to server.yaml (docs/guardrails.md).

+{{end}} + +

What they did

+

Over the last +{{range .Windows}}{{if eq . $.Window}}{{.}}{{else}}{{.}}{{end}} {{end}}

+{{if .Counts}} + + +{{range .Counts}}{{end}} +
WhereActionPolicyCategoryRequests
{{.Where}}{{.Action}}{{.Policy}}{{.Category}}{{.Count}}
+{{else}}

Nothing flagged, redacted or blocked.

{{end}} + +{{if .Events}} +

Recent

+ + +{{range .Events}} +{{end}} +
When (UTC)RequestWhereModelActionPolicyCategoryStatusms
{{.At}}{{.RequestID}}{{.Where}}{{.Model}}{{.Action}}{{.Policy}}{{.Category}}{{.Status}}{{.Ms}}
+

llmgw ctl request ID says what else a request went through.

+{{end}} +
{{end}} diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html index 6e4d43d5..193d2505 100644 --- a/internal/web/templates/nav.html +++ b/internal/web/templates/nav.html @@ -13,6 +13,9 @@ {{- if .Mirror}} Mirror {{- end}} + {{- if .Guard}} + Guardrails + {{- end}} {{- /* An organization is a panel of the people page for an administrator; /org is the managers' view, and leads an administrator there. */}}