CI / test (push) Successful in 2m52s
The console never mentioned them. A Guardrails view shows each policy with its mode, fail behaviour and checks, whether its judge models have replicas ready, where policies are attached (attach and detach from it), and what they did in a window. The admin API gains GET /api/v1/guard/events, and GET /api/v1/guard says what each policy does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
190 lines
6.4 KiB
Go
190 lines
6.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
)
|
|
|
|
// The guardrails, as the console shows them: the policies server.yaml
|
|
// defines and what each checks, where they are attached, whether the judge
|
|
// models they lean on are serving, and what they did in a window.
|
|
|
|
// guardWindows are the windows W steps through.
|
|
var guardWindows = []string{"24h", "7d", "1h"}
|
|
|
|
type guardState struct {
|
|
view adminapi.GuardView
|
|
events adminapi.GuardEventsView
|
|
err error
|
|
// window is an index into guardWindows.
|
|
window int
|
|
}
|
|
|
|
type consoleGuardMsg struct {
|
|
view adminapi.GuardView
|
|
events adminapi.GuardEventsView
|
|
err error
|
|
}
|
|
|
|
func (m consoleModel) fetchGuard() tea.Cmd {
|
|
c, since := m.client, guardWindows[m.guard.window]
|
|
return func() tea.Msg {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
var msg consoleGuardMsg
|
|
if msg.err = c.get(ctx, "/api/v1/guard", &msg.view); msg.err != nil {
|
|
return msg
|
|
}
|
|
// What they did is a second question; a store without the record
|
|
// still shows the policies.
|
|
_ = c.get(ctx, "/api/v1/guard/events?since="+since, &msg.events)
|
|
return msg
|
|
}
|
|
}
|
|
|
|
func (m consoleModel) guardPanel(pw int) (string, string, string) {
|
|
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
|
if m.inPanel {
|
|
keys = "↑↓ attachment · Enter actions · A attach · W window · ← back · Q leave"
|
|
}
|
|
g := m.guard
|
|
var b strings.Builder
|
|
b.WriteString("\n " + sDim.Render("What requests and answers are checked for, where, and what that did. A to attach a policy.") + "\n\n")
|
|
if g.err != nil {
|
|
if strings.Contains(g.err.Error(), "501") || strings.Contains(g.err.Error(), "no guard policies") {
|
|
b.WriteString(" " + sDim.Render("No policies: define them under guard: in server.yaml (Settings), then restart."))
|
|
} else {
|
|
b.WriteString(" " + sBad.Render(g.err.Error()))
|
|
}
|
|
return "Guardrails", b.String(), keys
|
|
}
|
|
if len(g.view.Details) == 0 && len(g.view.Policies) == 0 {
|
|
b.WriteString(" " + sDim.Render("asking the control plane…"))
|
|
return "Guardrails", b.String(), keys
|
|
}
|
|
|
|
def := "none"
|
|
if len(g.view.Default) > 0 {
|
|
def = strings.Join(g.view.Default, ", ")
|
|
}
|
|
b.WriteString(" " + sTitle.Render("POLICIES") + " " + sDim.Render("on every request: "+def) + "\n")
|
|
for _, p := range g.view.Details {
|
|
line := fmt.Sprintf(" %-14s %s · fail %s", p.Name, p.Mode, p.Fail)
|
|
if p.NoStore {
|
|
line += " · keeps nothing"
|
|
}
|
|
if p.Judge != "" {
|
|
line += " · judge " + p.Judge + " " + m.judgeState(p.Judge)
|
|
}
|
|
b.WriteString(line + "\n")
|
|
for _, c := range p.Checks {
|
|
b.WriteString(" " + strings.Repeat(" ", 15) + sDim.Render(c) + "\n")
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n " + sTitle.Render("ATTACHED") + " " + sDim.Render("besides the default, to part of the tenancy tree, a person or a key") + "\n")
|
|
if len(g.view.Attachments) == 0 {
|
|
b.WriteString(" " + sDim.Render("Nowhere. A to attach one.") + "\n")
|
|
} else {
|
|
var rows [][]string
|
|
for _, a := range g.view.Attachments {
|
|
who := a.Name
|
|
if who == "" {
|
|
who = a.ScopeID
|
|
}
|
|
rows = append(rows, []string{a.Scope, who, a.Policy})
|
|
}
|
|
b.WriteString(table([]string{"SCOPE", "WHO", "POLICY"}, rows, m.row, pw, m.inPanel) + "\n")
|
|
}
|
|
|
|
window := guardWindows[g.window]
|
|
b.WriteString("\n " + sTitle.Render("IN THE LAST "+strings.ToUpper(window)) + " " + sDim.Render("W for another window") + "\n")
|
|
if len(g.events.Counts) == 0 {
|
|
b.WriteString(" " + sDim.Render("Nothing flagged, redacted or blocked.") + "\n")
|
|
} else {
|
|
var rows [][]string
|
|
for i, c := range g.events.Counts {
|
|
if i == 10 {
|
|
break
|
|
}
|
|
rows = append(rows, []string{c.Action, c.Policy, dash(c.Category), dash(c.Project), fmt.Sprint(c.Count)})
|
|
}
|
|
b.WriteString(table([]string{"ACTION", "POLICY", "CATEGORY", "PROJECT", "REQUESTS"}, rows, -1, pw, false) + "\n")
|
|
}
|
|
if len(g.events.Events) > 0 {
|
|
b.WriteString("\n " + sTitle.Render("NEWEST") + "\n")
|
|
var rows [][]string
|
|
for i, e := range g.events.Events {
|
|
if i == 10 {
|
|
break
|
|
}
|
|
rows = append(rows, []string{e.At.Local().Format("01-02 15:04:05"), e.Action, e.Policy, dash(e.Category),
|
|
e.Model, fmt.Sprint(e.Status), fmt.Sprintf("%dms", e.GuardMs), e.RequestID})
|
|
}
|
|
b.WriteString(table([]string{"AT", "ACTION", "POLICY", "CATEGORY", "MODEL", "STATUS", "GUARD", "REQUEST"}, rows, -1, pw, false))
|
|
}
|
|
return "Guardrails", b.String(), keys
|
|
}
|
|
|
|
// judgeState is how many of a judge model's replicas are ready: a policy
|
|
// that fails closed refuses everything while it has none.
|
|
func (m consoleModel) judgeState(model string) string {
|
|
for _, md := range m.data.models {
|
|
if md.Name == model {
|
|
s := fmt.Sprintf("(%d/%d ready)", md.Ready, md.Desired)
|
|
if md.Ready == 0 {
|
|
return sBad.Render(s)
|
|
}
|
|
return sOK.Render(s)
|
|
}
|
|
}
|
|
return sBad.Render("(not in the catalog)")
|
|
}
|
|
|
|
// guardAttachForm attaches a policy.
|
|
func (m *consoleModel) guardAttachForm() {
|
|
if len(m.guard.view.Policies) == 0 {
|
|
return
|
|
}
|
|
m.openForm(&formDialog{title: "Attach a guardrail policy",
|
|
note: "Requests from there are checked by it as well as by the default.",
|
|
fields: []formField{
|
|
choiceF("to", []string{"project", "team", "org", "user", "key"}, "project"),
|
|
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, or the ID"),
|
|
choiceF("policy", m.guard.view.Policies, m.guard.view.Policies[0]),
|
|
},
|
|
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
|
if strings.TrimSpace(v[1]) == "" {
|
|
return nil, fmt.Errorf("which %s?", v[0])
|
|
}
|
|
req := adminapi.GuardAttachmentRequest{Target: v[0] + ":" + strings.TrimSpace(v[1]), Policy: v[2]}
|
|
return m.call("Attach "+v[2], http.MethodPost, "/api/v1/guard/attachments", req, nil), nil
|
|
}})
|
|
}
|
|
|
|
// guardAttachmentMenu is what can be done to one attachment.
|
|
func (m consoleModel) guardAttachmentMenu(a adminapi.GuardAttachmentView) *menuDialog {
|
|
who := a.Name
|
|
if who == "" {
|
|
who = a.ScopeID
|
|
}
|
|
return &menuDialog{title: a.Policy + " on " + a.Scope + " " + who, items: []menuItem{
|
|
{"Detach…", func(m *consoleModel) tea.Cmd {
|
|
m.confirmD = &confirmDialog{title: "Detach " + a.Policy,
|
|
text: "Requests from " + who + " are then checked by the default policies alone.",
|
|
yes: func(m *consoleModel) tea.Cmd {
|
|
req := adminapi.GuardAttachmentRequest{Target: a.Scope + ":" + a.ScopeID, Policy: a.Policy}
|
|
return m.call("Detach "+a.Policy, http.MethodDelete, "/api/v1/guard/attachments", req, nil)
|
|
}}
|
|
return nil
|
|
}},
|
|
}}
|
|
}
|