From ec737cffb58c06d86b6c0b2532f232d71ef7cf4b Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sun, 4 Oct 2026 12:36:02 +0200 Subject: [PATCH] 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) --- internal/guard/gate.go | 7 +++++++ internal/guard/gate_test.go | 8 ++++++++ internal/guard/guard.go | 10 ++++++++++ internal/router/guard.go | 18 ++++++++++++++++++ internal/router/guard_test.go | 34 ++++++++++++++++++++++++++++++++++ internal/router/handlers.go | 14 ++++++++++++++ internal/router/memory.go | 7 +++++++ internal/router/router.go | 8 +++++++- internal/router/threads.go | 2 +- 9 files changed, 106 insertions(+), 2 deletions(-) diff --git a/internal/guard/gate.go b/internal/guard/gate.go index c29adf15..76e34198 100644 --- a/internal/guard/gate.go +++ b/internal/guard/gate.go @@ -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() diff --git a/internal/guard/gate_test.go b/internal/guard/gate_test.go index 8071988c..a8622e41 100644 --- a/internal/guard/gate_test.go +++ b/internal/guard/gate_test.go @@ -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") + } +} diff --git a/internal/guard/guard.go b/internal/guard/guard.go index 7bb3a8a6..dfded9e5 100644 --- a/internal/guard/guard.go +++ b/internal/guard/guard.go @@ -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. diff --git a/internal/router/guard.go b/internal/router/guard.go index 69fdb420..ca1fdc12 100644 --- a/internal/router/guard.go +++ b/internal/router/guard.go @@ -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 { diff --git a/internal/router/guard_test.go b/internal/router/guard_test.go index 78757fae..3435f2c7 100644 --- a/internal/router/guard_test.go +++ b/internal/router/guard_test.go @@ -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()) + } +} diff --git a/internal/router/handlers.go b/internal/router/handlers.go index cd281781..d3239c94 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -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. diff --git a/internal/router/memory.go b/internal/router/memory.go index 6cd88020..495e63b8 100644 --- a/internal/router/memory.go +++ b/internal/router/memory.go @@ -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) } diff --git a/internal/router/router.go b/internal/router/router.go index 80b8d745..5870b809 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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 { diff --git a/internal/router/threads.go b/internal/router/threads.go index 374470eb..04c12a2a 100644 --- a/internal/router/threads.go +++ b/internal/router/threads.go @@ -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