diff --git a/BACKLOG.md b/BACKLOG.md index 49207d01..f4893d88 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -321,6 +321,15 @@ documented, left to `scaling.min`. (`context.WithoutCancel`, like `saveThread`) and only records. ### B4. Config, metrics, UI (S) +**Done (branch `guardrails-b`):** library in server.yaml, attachments via +`ctl guard`, the console's **Guardrails…** with "applies from" marks, and +the portal's `/admin/guard`; the metrics and alerts below (judge latency +labelled by model only at the judge; the check latency by stage); `ctl +request` shows the verdict; the chat page says "stopped by policy"; the +profile lists effective policies; docs/guardrails.md and data.md written; +the access log carries action, stage and policy only. Attaching from the +portal is not done — the page shows; the console and ctl change. + - Gateway-level policy library in `server.yaml` under `guard:` (see the YAML in the review, reproduced in `docs/guardrails.md` to be written); attachments to scopes in the store via `llmgw keys guard`, the console diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bbbd2b2..1ca8b632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,19 @@ APIs change between minors without a deprecation period. streamed answer is read every 600 characters. - **Isolation:** a judge model is hidden from clients and refused to them. It is critical by default, and cannot be loaded on demand. +- **Seeing what the guardrails do.** + - **Metrics:** `llmgw_guard_checks_total` (stage, policy, action, + category, mode), latency for checks and for the judge, judge errors, + fail-open, redactions by kind, stopped streams, and blocks per tenant. + - **Alerts:** judged traffic served unjudged, a slow or unreachable + judge, a tenant's blocks spiking, a secret in an answer. + - **Pages and commands:** `/admin/guard` shows counts by place, policy + and category and the recent requests by id; `ctl request` shows the + verdict; the profile page lists the policies in force where the person + works; the console's organization rows have **Guardrails…** and a + column. + - **The chat page** says when a request was refused or an answer stopped + by policy, instead of showing an error. ### Security diff --git a/docs/console.md b/docs/console.md index 458347cd..92e86e2f 100644 --- a/docs/console.md +++ b/docs/console.md @@ -170,6 +170,17 @@ empty. Limits are requests and tokens a minute, a budget per day or month, and the models allowed. Each level only narrows what is above it. +**Guardrails…** on a row lists the guardrail policies server.yaml defines. +Each policy is marked in one of three ways: + +- attached here (✓): Enter detaches it; +- applying from above (·): from everywhere, or from the organization or + team named; +- neither: Enter attaches it. + +The **GUARDRAILS** column names those attached to the row itself. Policies +narrow like limits; see [guardrails.md](guardrails.md). + ## Help **Manual** lists every page there is: these, the installation and operation diff --git a/internal/cli/tui_actions_test.go b/internal/cli/tui_actions_test.go index c425b6c1..485974fc 100644 --- a/internal/cli/tui_actions_test.go +++ b/internal/cli/tui_actions_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "os" + "slices" "strings" "sync" "testing" @@ -17,6 +18,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/config" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" "gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve" + "gitea.mynodes.xyz/ilker/llmgw/internal/guard" "gitea.mynodes.xyz/ilker/llmgw/internal/modelplan" "gitea.mynodes.xyz/ilker/llmgw/internal/promq" "gitea.mynodes.xyz/ilker/llmgw/internal/store" @@ -614,3 +616,36 @@ func TestAWindowsSamplesFillTheChartsWidth(t *testing.T) { t.Errorf("a late start: %v", got) } } + +func TestGuardrailsSayWhereTheyApplyFrom(t *testing.T) { + var m consoleModel + m.people = peopleData{ + orgs: []orgRow{ + {level: "org", id: "o1", path: "Acme"}, + {level: "team", id: "t1", path: "Acme/ml"}, + {level: "project", id: "p1", path: "Acme/ml/train"}, + }, + guardPolicies: []string{"base", "finance", "strict"}, + guardDefault: []string{"base"}, + guardAt: []guard.Attachment{ + {Scope: "team", ScopeID: "t1", Policy: "finance"}, + {Scope: "project", ScopeID: "p1", Policy: "strict"}, + }, + } + labels := []string{} + for _, it := range m.guardMenu(m.people.orgs[2]).items { + labels = append(labels, it.label) + } + want := []string{"applies from everywhere", "applies from Acme/ml", "✓ strict"} + for i, w := range want { + if !strings.Contains(labels[i], w) { + t.Errorf("%q lacks %q", labels[i], w) + } + } + if c := m.guardCell(m.people.orgs[1]); c != "finance" { + t.Errorf("team cell %q", c) + } + if items := m.orgMenu(m.people.orgs[0]).items; !slices.ContainsFunc(items, func(i menuItem) bool { return i.label == "Guardrails…" }) { + t.Error("no Guardrails… in the menu") + } +} diff --git a/internal/cli/tui_people.go b/internal/cli/tui_people.go index a1003f1f..34c071da 100644 --- a/internal/cli/tui_people.go +++ b/internal/cli/tui_people.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "slices" "strconv" "strings" "sync" @@ -11,7 +12,9 @@ import ( tea "github.com/charmbracelet/bubbletea" + "gitea.mynodes.xyz/ilker/llmgw/internal/config" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" + "gitea.mynodes.xyz/ilker/llmgw/internal/guard" "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) @@ -67,13 +70,19 @@ type peopleData struct { limits map[string]store.Limits threads map[string]int // project id → hours kept, conversations notes map[string]int // project id → hours kept, agent notes - err error + // guardPolicies are server.yaml's guardrail policies, guardDefault those + // applied everywhere, and guardAt which are attached where. + guardPolicies []string + guardDefault []string + guardAt []guard.Attachment + err error } type consolePeopleMsg peopleData func (m consoleModel) fetchPeople() tea.Cmd { h := m.st + file := m.file return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -105,6 +114,14 @@ func (m consoleModel) fetchPeople() tea.Cmd { } } } + if cfg, err := config.Load(file); err == nil { + for name := range cfg.Guard.Policies { + d.guardPolicies = append(d.guardPolicies, name) + } + slices.Sort(d.guardPolicies) + d.guardDefault = cfg.Guard.Default + d.guardAt, _ = s.GuardAttachments(ctx) + } d.threads, d.notes = map[string]int{}, map[string]int{} for _, r := range d.orgs { if r.level != "project" { @@ -402,6 +419,7 @@ func (m consoleModel) orgMenu(r orgRow) *menuDialog { m.limitsForm(store.MemberScope(store.Scope(r.level)), r.id, "each member of "+r.path) return nil }}, + menuItem{"Guardrails…", func(m *consoleModel) tea.Cmd { m.menuD = m.guardMenu(r); return nil }}, menuItem{"Capacity quota…", func(m *consoleModel) tea.Cmd { q := adminapi.QuotaView{Scope: r.level + ":" + r.id} for _, x := range m.quotas { @@ -448,6 +466,75 @@ func (m consoleModel) orgMenu(r orgRow) *menuDialog { return &menuDialog{title: r.level + " " + r.path, items: items} } +// guardMenu attaches and detaches guardrail policies on a branch of the +// tree, saying which apply to it already from above. +func (m consoleModel) guardMenu(r orgRow) *menuDialog { + if len(m.people.guardPolicies) == 0 { + return &menuDialog{title: "Guardrails · " + r.path, items: []menuItem{{ + "No policies: add a guard: section to server.yaml (docs/guardrails.md)", func(*consoleModel) tea.Cmd { return nil }}}} + } + var items []menuItem + for _, pol := range m.people.guardPolicies { + label, attached := " "+pol, false + switch from := m.guardFrom(r, pol); { + case from == r.path: + label, attached = "✓ "+pol+" — attached here, Enter detaches", true + case from != "": + label = "· " + pol + " — applies from " + from + default: + label += " — Enter attaches" + } + target := r.level + ":" + r.id + items = append(items, menuItem{label, func(m *consoleModel) tea.Cmd { + if attached { + return m.runCLI("Detach "+pol, "ctl", "guard", "detach", target, pol) + } + return m.runCLI("Attach "+pol, "ctl", "guard", "attach", target, pol) + }}) + } + return &menuDialog{title: "Guardrails · " + r.path, items: items} +} + +// guardFrom is where a policy applies to a branch from: "everywhere", the +// branch's own path, an ancestor's, or "" when it does not. +func (m consoleModel) guardFrom(r orgRow, policy string) string { + if slices.Contains(m.people.guardDefault, policy) { + return "everywhere" + } + paths := map[string]string{} + for _, o := range m.people.orgs { + paths[o.level+":"+o.id] = o.path + } + from := "" + for _, a := range m.people.guardAt { + if a.Policy != policy { + continue + } + p, ok := paths[a.Scope+":"+a.ScopeID] + if !ok { + continue + } + if p == r.path { + return p + } + if strings.HasPrefix(r.path, p+"/") { + from = p + } + } + return from +} + +// guardCell names the policies attached to a branch itself. +func (m consoleModel) guardCell(r orgRow) string { + var names []string + for _, a := range m.people.guardAt { + if a.Scope == r.level && a.ScopeID == r.id { + names = append(names, a.Policy) + } + } + return strings.Join(names, ", ") +} + func (m *consoleModel) createOrgForm() { m.openForm(&formDialog{title: "New organization", fields: []formField{textF("name", "", "")}, @@ -504,7 +591,7 @@ func (m consoleModel) peoplePanel(pw int) (string, string, string) { rows = append(rows, []string{k.Name, k.Prefix, where, dash(k.OwnerEmail), keyState(k.Key, now), ago(k.LastUsedAt, now)}) } case cOrgs: - head = []string{"NAME", "LEVEL", "MEMBERS", "KEYS", "LIMITS"} + head = []string{"NAME", "LEVEL", "MEMBERS", "KEYS", "LIMITS", "GUARDRAILS"} for _, r := range m.people.orgs { name := r.name switch r.level { @@ -518,7 +605,8 @@ func (m consoleModel) peoplePanel(pw int) (string, string, string) { keysCell = strconv.Itoa(r.keys) } rows = append(rows, []string{name, r.level, strconv.Itoa(r.members), keysCell, - limitsCell(m.people.limits[store.LimitOwner{Scope: store.Scope(r.level), ID: r.id}.CounterKey()])}) + limitsCell(m.people.limits[store.LimitOwner{Scope: store.Scope(r.level), ID: r.id}.CounterKey()]), + m.guardCell(r)}) } } if len(rows) == 0 {