Guardrail services merged the stricter way; the request log keeps no restored value
CI / test (push) Failing after 7m44s

From review.
- A service two policies name is asked the stricter way of the two, both
  directions and block over flag, rather than as the first one says.
- A service answering null where it says unsafe gave no answer, not safe.
- With restore: true, the kept answer had the request's redacted values in
  it beside the redacted request; they are taken out again before it is
  kept.
- guardrails.md says what fail: open lets through, and that of n answers a
  service reads the first.
- The similar cache's embedding failures are logged once a minute, not per
  request; the page's test reads the gateway's local MCP handler under its
  lock; org/name:Q4_K_M is not taken for a Hugging Face id to read as
  safetensors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-29 11:03:57 +01:00
1 parent f94d902cc3
commit 5eac62d556
10 files changed
+74 -8

No files matched your search

+4 -1
View File
@@ -309,7 +309,10 @@ service can stop a streamed answer only where it is held back
it by then, and what the service finds ends the stream with
`content_filter`, flags it and is recorded. The rule recorded is
`service:<name>:<reason>`. The service is told the text only, never who
sent it.
sent it. Under `fail: open`, the default, a service that cannot be asked
lets the text through — and a caller can make it fail, sending what it
refuses to read; give `fail: closed` where the service must decide. Of an
answer asked for with `n` above 1, the service reads the first.
## Where a policy applies
+2 -1
View File
@@ -220,7 +220,8 @@ func hubID(s string) bool {
if s == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.Contains(s, "://") {
return false
}
if strings.Count(s, "/") != 1 {
// org/name:Q4_K_M is a GGUF in a repository, which vLLM reads as one.
if strings.Count(s, "/") != 1 || strings.Contains(s, ":") {
return false
}
_, err := os.Stat(s)
+13 -2
View File
@@ -220,10 +220,21 @@ func Merge(names []string, ps []Policy) Policy {
out.RequireCapabilities = out.RequireCapabilities || p.RequireCapabilities
out.ValidateStructured = out.ValidateStructured || p.ValidateStructured
out.Judge = mergeJudge(out.Judge, p.Judge)
// Every service any policy asks, each once.
// Every service any policy asks, each once; one named by two is
// asked the stricter way of the two: both directions either asks,
// blocking where either blocks.
for _, sv := range p.Services {
if !slices.ContainsFunc(out.Services, func(e Service) bool { return e.Name == sv.Name }) {
i := slices.IndexFunc(out.Services, func(e Service) bool { return e.Name == sv.Name })
if i < 0 {
out.Services = append(out.Services, sv)
continue
}
e := &out.Services[i]
if e.action() == Flag && sv.action() == Block {
e.Action = "block"
}
if e.onInput() != sv.onInput() || e.onOutput() != sv.onOutput() {
e.On = "both"
}
}
out.MaxOutputTokens = minSet(out.MaxOutputTokens, p.MaxOutputTokens)
+2 -1
View File
@@ -144,7 +144,8 @@ func (s Service) ask(ctx context.Context, text string) (unsafe bool, reason stri
return false, "", fmt.Errorf("answer is not JSON: %w", err)
}
v, ok := at(doc, s.Blocked)
if !ok {
if !ok || v == nil {
// null is no answer: a service that cannot say is not saying safe.
return false, "", fmt.Errorf("the answer has no %s", s.Blocked)
}
if s.Reason != "" {
+9
View File
@@ -139,3 +139,12 @@ func TestAStreamedAnswerIsReadByAServiceOnce(t *testing.T) {
t.Errorf("the service was asked %d times: %q", len(*seen), *seen)
}
}
func TestAServiceNamedByTwoPoliciesIsAskedTheStricterWay(t *testing.T) {
a := Policy{Services: []Service{{Name: "cls", URL: "https://c", Blocked: "x", On: "input", Action: "flag"}}}
b := Policy{Services: []Service{{Name: "cls", URL: "https://c", Blocked: "x", On: "output", Action: "block"}}}
got := Merge([]string{"a", "b"}, []Policy{a, b}).Services
if len(got) != 1 || got[0].action() != Block || !got[0].onInput() || !got[0].onOutput() {
t.Errorf("%+v", got)
}
}
+2
View File
@@ -286,6 +286,8 @@ func (r *Router) Handler() http.Handler {
if r.mcpgw == nil {
return nil
}
r.mcpgw.mu.Lock()
defer r.mcpgw.mu.Unlock()
return r.mcpgw.local
})
}
+14 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"hash/fnv"
"net/http"
"strings"
"sync"
"time"
@@ -132,7 +133,7 @@ func (r *Router) keepRequest(ctx context.Context, req *parsedRequest, res dispat
now := time.Now()
b := storepkg.RequestBody{RequestID: req.RequestID, ProjectID: req.ProjectID, At: now,
ExpiresAt: now.Add(req.logRetain), Path: req.Path, Model: req.Model, ServedModel: served,
Status: status, Request: string(req.UpstreamBody), Response: res.outcome.Reply,
Status: status, Request: string(req.UpstreamBody), Response: withoutRestored(res.outcome.Reply, req.guardIn.Restore),
Truncated: res.outcome.ReplyTruncated}
if err := r.store.PutRequestBody(ctx, b); err != nil {
r.log.Warn("could not keep a request", "request_id", req.RequestID, "err", err)
@@ -192,3 +193,15 @@ func (r *Router) Replay(ctx context.Context, path, model string, body []byte) (i
}
return status, w.buf.Bytes(), took, nil
}
// withoutRestored is an answer with the values its request had redacted
// taken out again: the request is kept as the model read it, redacted, and
// the answer kept beside it must not hold what that took out.
func withoutRestored(reply string, restore map[string]string) string {
for ph, v := range restore {
if v != "" {
reply = strings.ReplaceAll(reply, v, ph)
}
}
return reply
}
+6
View File
@@ -212,3 +212,9 @@ func TestHeldTextGoesOutWithTheFinishEvent(t *testing.T) {
t.Errorf("%s / %s", out1, out2)
}
}
func TestAKeptAnswerHoldsNoValueItsRequestHadRedacted(t *testing.T) {
if got := withoutRestored("Sent to alice@example.com.", map[string]string{"[EMAIL_1]": "alice@example.com"}); got != "Sent to [EMAIL_1]." {
t.Errorf("%q", got)
}
}
+17
View File
@@ -94,6 +94,8 @@ type Router struct {
// cacheEmbedModel embeds the questions of projects that reuse answers
// to similar ones; empty, they reuse only the same request's.
cacheEmbedModel string
// embedWarned is when that model's failing was last logged.
embedWarned lastWarned
// extraMetrics are collectors other components publish on /metrics.
extraMetrics []prometheus.Collector
@@ -1378,3 +1380,18 @@ func loopbackOnly(listen string) bool {
a, err := netip.ParseAddr(host)
return err == nil && a.IsLoopback()
}
// lastWarned is when something was last logged, for logging it seldom.
type lastWarned struct {
mu sync.Mutex
at time.Time
}
// Swap records now and says when it was before.
func (w *lastWarned) Swap(now time.Time) time.Time {
w.mu.Lock()
defer w.mu.Unlock()
was := w.at
w.at = now
return was
}
+5 -2
View File
@@ -53,8 +53,11 @@ func (r *Router) similarLookup(ctx context.Context, req *parsedRequest, model st
}
vec, err := r.embedQuestion(ctx, question, req.inCluster)
if err != nil {
r.log.Warn("a question could not be embedded for the cache; only the same request is reused",
"model", r.cacheEmbedModel, "err", err)
// Once a minute at most: down, the model fails every miss.
if now := time.Now(); now.Sub(r.embedWarned.Swap(now)) > time.Minute {
r.log.Warn("a question could not be embedded for the cache; only the same request is reused",
"model", r.cacheEmbedModel, "err", err)
}
return nil, 0
}
if a, sim := r.answers.nearest(group, vec, set.Similar, set.TTL, time.Now()); a != nil && a.project == req.ProjectID {