no_store: refuse conversations, notes and models outside the cluster under a policy that keeps nothing

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 12:36:02 +02:00
1 parent 13e6f19301
commit ec737cffb5
9 files changed
+106 -2

No files matched your search

+7
View File
@@ -255,6 +255,13 @@ func (p *placeholders) rewrite(text string, spans []detect.Span) string {
var _ Guard = (*Gate)(nil)
// Directives are the policy in force's demands of the gateway. Not in
// shadow mode, which changes nothing.
func (g *Gate) Directives(s Subject) Directives {
p, _ := g.Policy(s)
return Directives{NoStore: p.NoStore && p.Mode == "enforce"}
}
// Input judges a request body.
func (g *Gate) Input(_ context.Context, s Subject, body []byte) (Verdict, error) {
start := g.now()
+8
View File
@@ -242,3 +242,11 @@ func TestBufferAllHoldsTheWholeAnswer(t *testing.T) {
t.Errorf("released before the end: %v", emit)
}
}
func TestNoStoreIsDemandedOnlyWhenEnforced(t *testing.T) {
enforce := gate(t, lib(map[string]Policy{"p": {NoStore: true}}, "p"))
shadow := gate(t, lib(map[string]Policy{"p": {NoStore: true, Mode: "shadow"}}, "p"))
if !enforce.Directives(Subject{}).NoStore || shadow.Directives(Subject{}).NoStore {
t.Error("no_store")
}
}
+10
View File
@@ -100,6 +100,16 @@ type Guard interface {
// Output is the filter for one attempt's answer, or nil when nothing
// watches it.
Output(ctx context.Context, s Subject) OutputFilter
// Directives are what the policy in force asks of the gateway itself,
// known before anything is stored or sent anywhere.
Directives(s Subject) Directives
}
// Directives are what a policy asks of the gateway beyond judging text.
type Directives struct {
// NoStore keeps nothing of the request: no stored conversation or
// memory note, no prefix routing, nothing sent outside the cluster.
NoStore bool
}
// OutputFilter watches one answer as it is relayed.
+18
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
@@ -40,6 +41,23 @@ func setGuardHeaders(h http.Header, v guard.Verdict) {
}
}
// callerSubject is who a request comes from, for a request that is not an
// inference: a memory note.
func callerSubject(req *http.Request) guard.Subject {
p, _ := auth.FromContext(req.Context())
return guard.Subject{KeyID: p.KeyID, UserID: p.UserID, ProjectID: p.ProjectID, TeamID: p.TeamID,
OrgID: p.OrgID, Path: req.URL.Path}
}
// noStore reports whether the policy in force for a request keeps nothing.
func (r *Router) noStore(s guard.Subject) bool {
return r.guard != nil && r.guard.Directives(s).NoStore
}
// errNoStore is a request asking the gateway to keep something under a
// policy that keeps nothing.
const errNoStoreMsg = "the gateway's policy for this caller keeps nothing: no stored conversations or memory notes, and no model outside the cluster"
// guardInput judges the request about to be dispatched. It answers the
// client itself and returns false when the request goes no further.
func (r *Router) guardInput(ctx context.Context, w http.ResponseWriter, req *parsedRequest, start time.Time) bool {
+34
View File
@@ -21,6 +21,7 @@ type wordGuard struct {
subject guard.Subject
// answers, when set, stops answers saying "hi".
answers bool
noStore bool
}
func (g *wordGuard) Input(_ context.Context, s guard.Subject, body []byte) (guard.Verdict, error) {
@@ -46,6 +47,8 @@ func (g *wordGuard) Output(context.Context, guard.Subject) guard.OutputFilter {
return &stopHi{}
}
func (g *wordGuard) Directives(guard.Subject) guard.Directives { return guard.Directives{NoStore: g.noStore} }
type stopHi struct{ v guard.Verdict }
func (f *stopHi) Buffered(body []byte) []byte {
@@ -159,3 +162,34 @@ func TestABlockedRequestIsRecordedWithItsVerdictAndCostsNothing(t *testing.T) {
time.Sleep(20 * time.Millisecond)
}
}
func TestAPolicyThatKeepsNothingRefusesWhatWouldBeKept(t *testing.T) {
rt, e := guardedRouter(t, &wordGuard{noStore: true})
rec := post(t, rt.Handler(), say("hello"), map[string]string{"x-llmgw-thread": "9b2f6c1e-4a0d"})
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"code":"no_store"`) || e.calls.Load() != 0 {
t.Errorf("a conversation under no_store: %d %s", rec.Code, rec.Body.String())
}
if rec := post(t, rt.Handler(), say("hello"), nil); rec.Code != http.StatusOK {
t.Errorf("a plain request under no_store: %d %s", rec.Code, rec.Body.String())
}
}
func TestTheGateJudgesRealTraffic(t *testing.T) {
g, err := guard.NewGate(guard.Config{Default: []string{"base"}, Policies: map[string]guard.Policy{"base": {
Input: guard.Rules{PII: map[string]string{"email": "redact"}},
Output: guard.Rules{Deny: []guard.Pattern{{Name: "greeting", Regex: `\bhi\b`, Action: "block"}}},
}}}, nil, nil)
if err != nil {
t.Fatal(err)
}
rt, e := guardedRouter(t, g)
rec := post(t, rt.Handler(), say("tell carol@example.com"), nil)
sent, _ := e.last.Load().([]byte)
if bytes.Contains(sent, []byte("carol@")) || !bytes.Contains(sent, []byte("[EMAIL_1]")) {
t.Errorf("the engine was sent %s", sent)
}
// The fake engine answers "hi", which the output policy stops.
if !strings.Contains(rec.Body.String(), "content_filter") || rec.Header().Get(guardHeader) != "blocked" {
t.Errorf("%v %s", rec.Header(), rec.Body.String())
}
}
+14
View File
@@ -299,6 +299,8 @@ type parsedRequest struct {
reservation accounting.Reservation
// guardIn is what the guard decided about the request.
guardIn guard.Verdict
// noStore is a policy that keeps nothing of this request.
noStore bool
}
// inferenceBody covers the fields the router reads across all inference
@@ -413,6 +415,18 @@ func (r *Router) handleInference(path string) http.HandlerFunc {
w.Header().Set("x-llmgw-queue-ms", strconv.FormatInt(queued.Milliseconds(), 10))
}
// A policy that keeps nothing is known before anything is kept: a
// conversation asked for is refused rather than quietly not stored,
// and a model outside the cluster is not sent the prompt.
if model, entry := r.resolve(parsed.Model); r.noStore(parsed.guardSubject(model)) {
parsed.noStore = true
if parsed.ThreadID != "" || entry.IsExternal() {
writeErrorCode(w, http.StatusBadRequest, "invalid_request_error", "no_store", errNoStoreMsg)
r.m.observeRequest(r.metricModel(parsed.Model), "", http.StatusBadRequest, time.Since(start), nil, 0)
return
}
}
// After the model is finally settled — a budget fallback may have
// changed it — because trimming is measured against the context of the
// model that will actually serve, not the one that was asked for.
+7
View File
@@ -112,6 +112,10 @@ func (r *Router) handlePutNote(w http.ResponseWriter, req *http.Request) {
writeMemoryError(w, err)
return
}
if r.noStore(callerSubject(req)) {
writeErrorCode(w, http.StatusForbidden, "invalid_request_error", "no_store", errNoStoreMsg)
return
}
var body struct {
Text string `json:"text"`
Tags []string `json:"tags"`
@@ -323,6 +327,9 @@ func (r *Router) callMemoryTool(req *http.Request, params json.RawMessage) map[s
switch call.Name {
case "remember":
if r.noStore(callerSubject(req)) {
return toolText(errNoStoreMsg, true)
}
if err := r.threads.PutNote(ctx, project, agent, store.Note{Key: a.Key, Text: a.Text, Tags: a.Tags}); err != nil {
return toolText(err.Error(), true)
}
+7 -1
View File
@@ -745,6 +745,10 @@ func (r *Router) dispatch(ctx context.Context, w http.ResponseWriter, req *parse
if authed && !r.permits(p, fb) {
continue
}
// Nor sent outside the cluster under a policy that keeps nothing.
if _, fe := r.resolve(fb); req.noStore && fe.IsExternal() {
continue
}
chain = append(chain, fb)
}
@@ -899,7 +903,9 @@ func (r *Router) dispatchOne(ctx context.Context, w http.ResponseWriter, req *pa
// with replicas that were never sent anything — the one thing that
// would make the estimate worse than no estimate at all.
if r.prefix != nil {
r.prefix.Observe(req.PromptPrefix, dec.Endpoint.ID)
if !req.noStore {
r.prefix.Observe(req.PromptPrefix, dec.Endpoint.ID)
}
}
if perr == nil || out.Wrote || !out.Retryable {
+1 -1
View File
@@ -221,7 +221,7 @@ const maxStoredReply = 128 << 10
// Zero for every request not continuing a conversation, which is what keeps
// the cost of this feature off the path of requests that do not use it.
func (p *parsedRequest) captureBytes() int {
if p.ThreadID == "" {
if p.ThreadID == "" || p.noStore {
return 0
}
return maxStoredReply