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:
Ilker ManapandClaude Opus 5.5 committed 2026-10-04 15:18:42 +02:00
1 parent 02aac84ca5
commit d55472c00a
5 files changed
+159 -3

No files matched your search

+9
View File
@@ -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
+13
View File
@@ -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
+11
View File
@@ -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
+35
View File
@@ -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")
}
}
+91 -3
View File
@@ -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 {