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:
1 parent
13e6f19301
commit
ec737cffb5
9 files changed
+106
-2
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user