Guardrails in the console: attach and detach on a branch, where each applies from, and a column
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
02aac84ca5
commit
d55472c00a
5 files changed
+159
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user