Files
Ilker ManapandClaude Opus 5.5 249a91aeec Guardrails turn off and on; a judge's refusal says which hazard; ctl guard judge ask
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>
2026-10-06 12:41:08 +02:00

148 lines
4.6 KiB
Go

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
// Off is the guardrails turned off (guard.enabled: false).
Off bool
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, Rule 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 func() guard.Config) {
p.hasGuard, p.guardLibrary = true, library
mux.HandleFunc("GET /admin/guard", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
ctx, l := r.Context(), langOf(r)
lib := library()
off := !lib.On()
d := guardPageData{Nav: p.navFor(r, admin, "guard"), Version: p.version, Window: r.URL.Query().Get("window"),
Windows: []string{"24h", "7d", "30d"}, Default: lib.Default, Off: off}
since, ok := guardWindows[d.Window]
if !ok {
d.Window, since = "7d", guardWindows["7d"]
}
for name := range lib.Policies {
d.Policies = append(d.Policies, name)
}
slices.Sort(d.Policies)
where := map[string]string{"": l.T("guard.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: guardScopeWord(l, a.Scope), Who: who, Policy: a.Policy})
}
}
counts, err := s.GuardCounts(ctx, time.Now().Add(-since))
if err != nil {
d.Error = l.Err(err)
}
for _, c := range counts {
d.Counts = append(d.Counts, guardCountRow{Where: name(c.ProjectID), Action: guardActionWord(l, 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 = l.Err(err)
}
for _, e := range events {
// The day as the reader writes one; the seconds stay, because
// events a second apart are what this table tells apart.
at := e.At.UTC()
d.Events = append(d.Events, guardEventRow{At: l.Date(at) + " " + at.Format("15:04:05"),
RequestID: e.RequestID, Where: name(e.ProjectID), Model: e.Model, Action: guardActionWord(l, e.Action),
Policy: e.Policy, Category: e.Category, Rule: e.Rule, Status: e.Status, Ms: e.GuardMs})
}
noStore(w)
render(w, r, "admin_guard", d)
}))
}
// guardScopeWord names the kind of scope a policy is attached to in the
// reader's language. A kind this page does not know is shown as stored.
func guardScopeWord(l *Lang, scope string) string {
switch scope {
case "org":
return l.T("guard.scope.org")
case "team":
return l.T("guard.scope.team")
case "project":
return l.T("guard.scope.project")
case "user":
return l.T("guard.scope.user")
case "key":
return l.T("guard.scope.key")
}
return scope
}
// guardActionWord is what a policy did, in the reader's language. The stored
// value stays the English word the events are recorded and queried by.
func guardActionWord(l *Lang, action string) string {
switch action {
case "flagged":
return l.T("guard.action.flagged")
case "redacted":
return l.T("guard.action.redacted")
case "blocked":
return l.T("guard.action.blocked")
}
return action
}