diff --git a/CHANGELOG.md b/CHANGELOG.md index e539ccd..fe378ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Request body inspection helpers** for HTTP policy evaluation + - `NewHTTPCallWithBody(method, host, path, body)` constructs a `Call` with the decoded request body exposed under `params.body`, so rules can match on body contents (e.g. `params.body.model == 'gpt-4'` or `hasSecrets(params.body.prompt)`). `body` is typed `any`, accepting a JSON object, array, or scalar. The existing `NewHTTPCall` is unchanged. + - `Engine.RequiresBody(scope)` reports, from compile-time analysis of the scope's rules, whether any rule references `params.body`. Gatekeepers can use this as a trigger to decide whether to buffer and parse the request body before evaluating. It fails safe: every idiomatic body reference is detected, and an unrecognized use of the `params` map (or an unknown scope) returns `true` so the body is buffered rather than silently skipped. + ## [0.4.0] - 2026-05-11 ### Added diff --git a/docs/content/guides/06-go-library.md b/docs/content/guides/06-go-library.md index b26cc20..fa3c097 100644 --- a/docs/content/guides/06-go-library.md +++ b/docs/content/guides/06-go-library.md @@ -70,6 +70,60 @@ A `Call` has three fields: The second argument to `Evaluate` is the scope name declared in your rule files. If the scope does not exist, `Evaluate` returns an error listing available scopes. +## Convenience constructors + +For common gatekeeper shapes, helper constructors build the `Call` for you. They set `Operation` and `Params` but leave `Context.Scope` unset -- assign it from your deployment convention. + +| Helper | Use | +|--------|-----| +| `NewHTTPCall(method, host, path)` | An outbound HTTP request. Exposes `params.method`, `params.host`, `params.path`. | +| `NewHTTPCallWithBody(method, host, path, body)` | Same, plus the decoded request body under `params.body`. | +| `NewMCPCall(tool, params)` | An MCP tool call. `Operation` is the tool name; `params` is passed through. | + +> The built-in `keep-llm-gateway` does **not** use these -- it decomposes provider requests into semantic calls (`params.model`, `params.system`, `params.text`, ...). The HTTP helpers are for embedding Keep in your own HTTP proxy or middleware. + +### Inspecting the request body + +`NewHTTPCallWithBody` exposes the decoded body so rules can match on it: + +```yaml +- name: block-gpt4 + match: + when: "params.body.model == 'gpt-4'" + action: deny +``` + +`body` is typed `any`, so it accepts a JSON object (`map[string]any`), an array (`[]any`), or a scalar. + +Buffering and parsing a request body is not free, so only do it when a rule in the scope actually reads it. `Engine.RequiresBody(scope)` answers that from a compile-time scan of the scope's rules: + +```go +func middleware(engine *keep.Engine, scope string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body any // any JSON shape: object, array, or scalar + if engine.RequiresBody(scope) { + raw, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "cannot read request body", http.StatusBadRequest) + return + } + r.Body = io.NopCloser(bytes.NewReader(raw)) // restore for the proxy + if len(raw) > 0 { + if err := json.Unmarshal(raw, &body); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + } + } + call := keep.NewHTTPCallWithBody(r.Method, r.Host, r.URL.Path, body) + call.Context.Scope = scope + // ... evaluate and act on the decision + } +} +``` + +`RequiresBody` fails safe: it detects every idiomatic body reference (`params.body.x`, `params["body"]`, `has(params.body)`, comprehensions, the `in` operator), and for any unrecognized use of the `params` map -- or an unknown scope name -- it returns `true` so the body is buffered rather than silently skipped. + ## Handle decisions `result.Decision` is one of three values: diff --git a/helpers.go b/helpers.go index 96ae73c..04e6264 100644 --- a/helpers.go +++ b/helpers.go @@ -41,6 +41,28 @@ func NewHTTPCall(method, host, path string) Call { } } +// NewHTTPCallWithBody constructs a Call for HTTP request policy evaluation, +// including the parsed request body under params.body. It behaves exactly like +// NewHTTPCall (method is uppercased, path is expected to include a leading +// slash, Context.Scope is left unset) but additionally exposes the body to +// rules that inspect it (e.g. `params.body.model == 'gpt-4'` or +// `hasSecrets(params.body.prompt)`). +// +// body is the decoded request body and may be any JSON-shaped value: an object +// (map[string]any), an array ([]any), or a scalar. It is stored as-is, so a nil +// body still sets params.body (to nil). With a nil body, content-inspection +// rules such as params.body.model == 'gpt-4' will not match, because CEL treats +// the nil navigation as a missing value and the engine resolves that to false; +// has(params.body) is nonetheless always true for calls built with this helper, +// so test for a populated body with params.body != null instead. Use +// Engine.RequiresBody to decide whether buffering and parsing the body is +// worthwhile before calling this. +func NewHTTPCallWithBody(method, host, path string, body any) Call { + call := NewHTTPCall(method, host, path) + call.Params[engine.ParamBody] = body + return call +} + // NewMCPCall constructs a Call for MCP tool-use policy evaluation. // The operation is the tool name as-is. Params are passed through directly (may be nil). // Context.Scope is not set — callers should assign it based on their deployment convention. diff --git a/helpers_test.go b/helpers_test.go index 0c8c8bb..0120722 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -37,6 +37,60 @@ func TestNewHTTPCallMethodUppercased(t *testing.T) { } } +func TestNewHTTPCallWithBody(t *testing.T) { + body := map[string]any{"model": "gpt-4", "stream": true} + call := NewHTTPCallWithBody("post", "api.openai.com", "/v1/chat/completions", body) + + // Behaves like NewHTTPCall for the operation and base params. + if call.Operation != "POST api.openai.com/v1/chat/completions" { + t.Errorf("Operation = %q, want %q", call.Operation, "POST api.openai.com/v1/chat/completions") + } + if call.Params["method"] != "POST" { + t.Errorf("Params[method] = %v, want %q", call.Params["method"], "POST") + } + if call.Params["host"] != "api.openai.com" { + t.Errorf("Params[host] = %v, want %q", call.Params["host"], "api.openai.com") + } + if call.Context.Timestamp.IsZero() { + t.Error("Timestamp should not be zero") + } + + // Body is exposed under params.body. + got, ok := call.Params["body"].(map[string]any) + if !ok { + t.Fatalf("Params[body] = %T, want map[string]any", call.Params["body"]) + } + if got["model"] != "gpt-4" { + t.Errorf("Params[body][model] = %v, want %q", got["model"], "gpt-4") + } +} + +func TestNewHTTPCallWithBody_NilBody(t *testing.T) { + call := NewHTTPCallWithBody("GET", "example.com", "/", nil) + if _, present := call.Params["body"]; !present { + t.Error("Params[body] key should be present even for a nil body") + } + if call.Params["body"] != nil { + t.Errorf("Params[body] = %v, want nil", call.Params["body"]) + } +} + +// TestNewHTTPCallWithBody_NonObjectBody verifies the helper accepts bodies that +// are not JSON objects — a top-level array (e.g. a batch request) or a scalar — +// since `body` is typed `any`, not `map[string]any`. +func TestNewHTTPCallWithBody_NonObjectBody(t *testing.T) { + arr := []any{map[string]any{"model": "gpt-4"}, map[string]any{"model": "gpt-3.5"}} + call := NewHTTPCallWithBody("POST", "api.openai.com", "/v1/batch", arr) + + got, ok := call.Params["body"].([]any) + if !ok { + t.Fatalf("Params[body] = %T, want []any", call.Params["body"]) + } + if len(got) != 2 { + t.Errorf("len(Params[body]) = %d, want 2", len(got)) + } +} + func TestNewMCPCall(t *testing.T) { params := map[string]any{"id": "123"} call := NewMCPCall("delete_issue", params) diff --git a/internal/cel/env.go b/internal/cel/env.go index f32c1b3..d896f19 100644 --- a/internal/cel/env.go +++ b/internal/cel/env.go @@ -7,6 +7,8 @@ import ( "time" "github.com/google/cel-go/cel" + celast "github.com/google/cel-go/common/ast" + "github.com/google/cel-go/common/operators" "github.com/google/cel-go/common/types" "github.com/google/cel-go/common/types/ref" "github.com/google/cel-go/common/types/traits" @@ -277,6 +279,13 @@ func NewEnv(opts ...EnvOption) (*Env, error) { // Program is a compiled CEL expression ready for evaluation. type Program struct { prog cel.Program + // paramRefs is the set of top-level fields referenced off the params + // input variable (e.g. "body", "headers"), computed once at compile time. + paramRefs map[string]bool + // paramsOpaque is set when the expression uses the params map in a way the + // syntactic walker cannot resolve to specific fields (see collectParamRefs). + // When true, ReferencesParam answers true for every field — failing safe. + paramsOpaque bool } // Compile parses and type-checks a CEL expression string. @@ -290,7 +299,89 @@ func (e *Env) Compile(expr string) (*Program, error) { if err != nil { return nil, fmt.Errorf("cel: program %q: %w", expr, err) } - return &Program{prog: prog}, nil + refs, opaque := collectParamRefs(ast.NativeRep()) + return &Program{prog: prog, paramRefs: refs, paramsOpaque: opaque}, nil +} + +// ReferencesParam reports whether the compiled expression may read the given +// field off the params input variable. It returns true for idiomatic dot- or +// index-access (params.body, params["body"], including nested forms like +// params.body.foo), and — failing safe — for every field when the expression +// touches the params map opaquely (see collectParamRefs). The result is +// computed at compile time, so this is a cheap lookup. +func (p *Program) ReferencesParam(field string) bool { + if p == nil { + return false + } + return p.paramsOpaque || p.paramRefs[field] +} + +// collectParamRefs walks a compiled AST and returns the set of top-level fields +// read off the params input variable, plus an "opaque" flag. It recognizes both +// dot-selection (params.body) and index access with a string literal +// (params["body"]), including those nested inside macros (has, exists, map, +// filter), the `in` operator, and ternaries — i.e. every idiomatic way a rule +// reads a field. +// +// Because it is a purely syntactic matcher, it cannot resolve a field read +// through a computed index key (params["bo"+"dy"]), the whole params map used as +// a value (size(params), dyn(params).body), or params rebound to a comprehension +// variable. Rather than silently report "no reference" for those — which would +// let a fail-safe trigger (see Engine.RequiresBody) skip a field the rule +// actually reads — it returns opaque=true whenever params is used in any form +// other than a recognized field access. Callers then treat the expression as +// potentially referencing any field. +// +// Detection: every occurrence of the bare params identifier is either consumed +// by a recognized field access (a select on params, or an index of params with a +// string-literal key) or it is not. If any occurrence is left unconsumed, params +// escaped into an unresolvable position and opaque is set. +// +// Coupled to cel-go/common/ast (PostOrderVisit, SelectKind/CallKind, the index +// operators); audit this walk when upgrading cel-go. +func collectParamRefs(a *celast.AST) (refs map[string]bool, opaque bool) { + if a == nil { + return nil, false + } + refs = map[string]bool{} + var total, consumed int + celast.PostOrderVisit(a.Expr(), celast.NewExprVisitor(func(e celast.Expr) { + switch e.Kind() { + case celast.IdentKind: + if isParamsIdent(e) { + total++ + } + case celast.SelectKind: + sel := e.AsSelect() + if isParamsIdent(sel.Operand()) { + refs[sel.FieldName()] = true + consumed++ + } + case celast.CallKind: + call := e.AsCall() + if call.FunctionName() != operators.Index && call.FunctionName() != operators.OptIndex { + return + } + args := call.Args() + if len(args) != 2 || !isParamsIdent(args[0]) || args[1].Kind() != celast.LiteralKind { + return + } + if s, ok := args[1].AsLiteral().Value().(string); ok { + refs[s] = true + consumed++ + } + } + })) + opaque = total > consumed + if len(refs) == 0 { + refs = nil + } + return refs, opaque +} + +// isParamsIdent reports whether e is the bare params input identifier. +func isParamsIdent(e celast.Expr) bool { + return e.Kind() == celast.IdentKind && e.AsIdent() == "params" } // Eval evaluates a compiled program against the given params and context. diff --git a/internal/cel/refs_test.go b/internal/cel/refs_test.go new file mode 100644 index 0000000..10966d5 --- /dev/null +++ b/internal/cel/refs_test.go @@ -0,0 +1,116 @@ +package cel_test + +import ( + "testing" + + keepcel "github.com/majorcontext/keep/internal/cel" +) + +func TestReferencesParam(t *testing.T) { + env := mustNewEnv(t) + + tests := []struct { + name string + expr string + field string + want bool + }{ + {"dot select", "params.body == 'x'", "body", true}, + {"index string literal", `params["body"] == 'x'`, "body", true}, + {"nested select", "params.body.model == 'gpt-4'", "body", true}, + {"index into body", "size(params.body[0]) > 0", "body", true}, + {"inside function call", "hasSecrets(params.body.prompt)", "body", true}, + {"presence test", "has(params.body)", "body", true}, + {"deeply nested", "params.body.messages[0].content != ''", "body", true}, + {"other field only", "params.method == 'POST'", "body", false}, + {"different field requested", "params.body == 'x'", "headers", false}, + {"no params at all", "context.agent_id == 'a'", "body", false}, + {"field named after body substr", "params.bodyguard == 'x'", "body", false}, + // Opaque uses of the params map cannot be resolved to a field, so + // detection fails SAFE: ReferencesParam answers true for every field. + {"dynamic index key", "params[params.method] == 'x'", "body", true}, + {"computed string index key", `params["bo" + "dy"] == 'x'`, "body", true}, + {"whole-map op", "size(params) > 0", "body", true}, + {"params wrapped in dyn", "dyn(params).body == 'x'", "body", true}, + {"opaque use alongside specific field", "params.a == 'x' && size(params) > 0", "body", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prog := mustCompile(t, env, tt.expr) + if got := prog.ReferencesParam(tt.field); got != tt.want { + t.Errorf("ReferencesParam(%q) on %q = %v, want %v", tt.field, tt.expr, got, tt.want) + } + }) + } +} + +// TestReferencesParam_FailSafeDoesNotOverTrigger verifies that recognized +// single-field access does NOT mark the program opaque: a rule that only reads +// params.method must not report a reference to params.body (which would force a +// gatekeeper to buffer the body for every method-only rule). +func TestReferencesParam_FailSafeDoesNotOverTrigger(t *testing.T) { + env := mustNewEnv(t) + prog := mustCompile(t, env, "params.method == 'POST' && params.host == 'api.example.com'") + if prog.ReferencesParam("body") { + t.Error("ReferencesParam(\"body\") = true for a method/host-only rule, want false") + } +} + +func TestReferencesParam_MultipleFields(t *testing.T) { + env := mustNewEnv(t) + prog := mustCompile(t, env, "params.body.model == 'gpt-4' && params.headers['x'] == 'y'") + + for _, f := range []string{"body", "headers"} { + if !prog.ReferencesParam(f) { + t.Errorf("ReferencesParam(%q) = false, want true", f) + } + } + if prog.ReferencesParam("method") { + t.Error("ReferencesParam(\"method\") = true, want false") + } +} + +// TestParamsBodyNilSemantics pins the CEL runtime behavior that +// NewHTTPCallWithBody's docstring relies on: a nil body is stored under the +// "body" key, so has(params.body) is true while params.body != null is false. +// This guards the documented contract against a cel-go change to nil-valued map +// key handling. +func TestParamsBodyNilSemantics(t *testing.T) { + env := mustNewEnv(t) + + withNil := map[string]any{"body": nil} // NewHTTPCallWithBody(nil) + withVal := map[string]any{"body": map[string]any{"model": "x"}} // populated body + absent := map[string]any{} // NewHTTPCall (no body key) + + tests := []struct { + expr string + params map[string]any + want bool + }{ + {"has(params.body)", withNil, true}, + {"has(params.body)", absent, false}, + {"params.body != null", withNil, false}, + {"params.body != null", withVal, true}, + {"params.body != null", absent, false}, + } + for _, tt := range tests { + prog := mustCompile(t, env, tt.expr) + got, err := prog.Eval(tt.params, nil) + if err != nil { + t.Fatalf("Eval(%q, %v) error: %v", tt.expr, tt.params, err) + } + if got != tt.want { + t.Errorf("Eval(%q, %v) = %v, want %v", tt.expr, tt.params, got, tt.want) + } + } +} + +func TestReferencesParam_NilProgram(t *testing.T) { + // A nil *Program must not panic and reports no references. This matters + // because a rule with no when clause has a nil compiled program. + var prog *keepcel.Program + if prog.ReferencesParam("body") { + t.Error("nil program ReferencesParam = true, want false") + } +} diff --git a/internal/engine/eval.go b/internal/engine/eval.go index 95fc52d..798db06 100644 --- a/internal/engine/eval.go +++ b/internal/engine/eval.go @@ -131,6 +131,7 @@ type Evaluator struct { secrets *secrets.Detector caseSensitive bool judgeFunc JudgeHandler + requiresBody bool // precomputed: any rule references params.body } // SetJudgeFunc sets the judge handler for this evaluator. @@ -138,6 +139,30 @@ func (ev *Evaluator) SetJudgeFunc(fn JudgeHandler) { ev.judgeFunc = fn } +// ParamBody is the params key under which the request body is exposed to rules +// (params.body). It is the single source of truth shared by the call helpers +// that populate the body and the analysis that detects references to it. +const ParamBody = "body" + +// RequiresBody reports whether any compiled rule in this scope references the +// request body (params.body). Callers can use this to decide whether the +// (potentially expensive) request body needs to be buffered and supplied +// before evaluation. The answer is fixed at compile time and precomputed in +// NewEvaluator, so this is an O(1) lookup safe to call per request. +func (ev *Evaluator) RequiresBody() bool { + return ev.requiresBody +} + +// computeRequiresBody reports whether any compiled rule references params.body. +func computeRequiresBody(rules []compiledRule) bool { + for _, cr := range rules { + if cr.program.ReferencesParam(ParamBody) { + return true + } + } + return false +} + // NewEvaluator creates an evaluator for a scope. Compiles all CEL expressions // and redact patterns at creation time. Returns an error if any expression // fails to compile. @@ -201,6 +226,7 @@ func NewEvaluator( scope: scope, secrets: detector, caseSensitive: caseSensitive, + requiresBody: computeRequiresBody(compiled), }, nil } diff --git a/internal/engine/requires_body_test.go b/internal/engine/requires_body_test.go new file mode 100644 index 0000000..8d4e678 --- /dev/null +++ b/internal/engine/requires_body_test.go @@ -0,0 +1,96 @@ +package engine + +import ( + "testing" + + keepcel "github.com/majorcontext/keep/internal/cel" + "github.com/majorcontext/keep/internal/config" +) + +func TestEvaluator_RequiresBody(t *testing.T) { + tests := []struct { + name string + rules []config.Rule + want bool + }{ + { + name: "no rules", + rules: nil, + want: false, + }, + { + name: "rule without when clause", + rules: []config.Rule{ + {Name: "allow-all", Action: config.ActionLog, Match: config.Match{Operation: "*"}}, + }, + want: false, + }, + { + name: "when references other params only", + rules: []config.Rule{ + {Name: "method", Action: config.ActionDeny, Match: config.Match{When: "params.method == 'DELETE'"}}, + }, + want: false, + }, + { + name: "single rule references body", + rules: []config.Rule{ + {Name: "model", Action: config.ActionDeny, Match: config.Match{When: "params.body.model == 'gpt-4'"}}, + }, + want: true, + }, + { + name: "body reference in one of several rules", + rules: []config.Rule{ + {Name: "method", Action: config.ActionDeny, Match: config.Match{When: "params.method == 'DELETE'"}}, + {Name: "no-when", Action: config.ActionLog, Match: config.Match{Operation: "GET *"}}, + {Name: "secrets", Action: config.ActionDeny, Match: config.Match{When: "hasSecrets(params.body.prompt)"}}, + }, + want: true, + }, + { + name: "index access counts", + rules: []config.Rule{ + {Name: "idx", Action: config.ActionDeny, Match: config.Match{When: `params["body"] != ''`}}, + }, + want: true, + }, + { + name: "opaque params use fails safe", + rules: []config.Rule{ + {Name: "method", Action: config.ActionDeny, Match: config.Match{When: "params.method == 'DELETE'"}}, + {Name: "whole-map", Action: config.ActionDeny, Match: config.Match{When: "size(params) > 20"}}, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := makeEvaluator(t, tt.rules) + if got := ev.RequiresBody(); got != tt.want { + t.Errorf("RequiresBody() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestEvaluator_RequiresBody_ViaAlias verifies body references hidden behind a +// rule alias are still detected, since detection runs on the resolved expression. +func TestEvaluator_RequiresBody_ViaAlias(t *testing.T) { + env, err := keepcel.NewEnv() + if err != nil { + t.Fatal(err) + } + rules := []config.Rule{ + {Name: "aliased", Action: config.ActionDeny, Match: config.Match{When: "bigModel"}}, + } + aliases := map[string]string{"bigModel": "params.body.model == 'gpt-4'"} + ev, err := NewEvaluator(env, "test-scope", config.ModeEnforce, config.ErrorModeClosed, rules, aliases, nil, nil, false) + if err != nil { + t.Fatal(err) + } + if !ev.RequiresBody() { + t.Error("RequiresBody() = false, want true (body reference via alias)") + } +} diff --git a/keep.go b/keep.go index 93d3e7c..b822d29 100644 --- a/keep.go +++ b/keep.go @@ -163,6 +163,24 @@ func (e *Engine) Evaluate(ctx context.Context, call Call, scope string) (EvalRes return result, nil } +// RequiresBody reports whether any rule in the given scope references the +// request body (params.body). Gatekeepers can use this as a trigger signal to +// decide whether the request body must be buffered before calling Evaluate. +// +// It fails safe: an unknown scope returns true (err toward buffering) rather +// than silently false, so a misconfigured scope name never causes a body rule +// to be skipped. Evaluate still returns an error for the unknown scope, so the +// misconfiguration surfaces there. +func (e *Engine) RequiresBody(scope string) bool { + e.mu.RLock() + ev, ok := e.evaluators[scope] + e.mu.RUnlock() + if !ok { + return true + } + return ev.RequiresBody() +} + // Scopes returns the sorted list of loaded scope names. func (e *Engine) Scopes() []string { e.mu.RLock() diff --git a/keep_test.go b/keep_test.go index 6be0c90..ee6b85e 100644 --- a/keep_test.go +++ b/keep_test.go @@ -917,6 +917,102 @@ func TestRuleSet_DenyPrecedenceOverAllow(t *testing.T) { } } +func TestRequiresBody(t *testing.T) { + dir := writeRule(t, ` +scope: openai-gateway +mode: enforce +rules: + - name: block-gpt4 + match: + operation: "*" + when: "params.body.model == 'gpt-4'" + action: deny + message: "gpt-4 is not allowed." +`) + eng, err := keep.Load(dir) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + defer eng.Close() + + if !eng.RequiresBody("openai-gateway") { + t.Error("RequiresBody(openai-gateway) = false, want true") + } + // Unknown scope fails safe: returns true (err toward buffering) rather than + // silently skipping body buffering for a misconfigured scope name. + if !eng.RequiresBody("does-not-exist") { + t.Error("RequiresBody(does-not-exist) = false, want true (fail-safe)") + } +} + +func TestRequiresBody_NoBodyRules(t *testing.T) { + dir := writeRule(t, ` +scope: header-only +mode: enforce +rules: + - name: block-delete + match: + operation: "DELETE *" + action: deny +`) + eng, err := keep.Load(dir) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + defer eng.Close() + + if eng.RequiresBody("header-only") { + t.Error("RequiresBody(header-only) = true, want false") + } +} + +// TestRequiresBody_EndToEnd exercises the full path: a scope that requires the +// body, evaluated with a body supplied via NewHTTPCallWithBody. +func TestRequiresBody_EndToEnd(t *testing.T) { + dir := writeRule(t, ` +scope: openai-gateway +mode: enforce +rules: + - name: block-gpt4 + match: + when: "params.body.model == 'gpt-4'" + action: deny + message: "gpt-4 is not allowed." +`) + eng, err := keep.Load(dir) + if err != nil { + t.Fatalf("Load() error: %v", err) + } + defer eng.Close() + + if !eng.RequiresBody("openai-gateway") { + t.Fatal("expected scope to require body") + } + + // Matching body content => deny. + denyCall := keep.NewHTTPCallWithBody("POST", "api.openai.com", "/v1/chat/completions", + map[string]any{"model": "gpt-4"}) + result, err := eng.Evaluate(context.Background(), denyCall, "openai-gateway") + if err != nil { + t.Fatalf("Evaluate() error: %v", err) + } + if result.Decision != keep.Deny { + t.Errorf("Decision = %q, want %q", result.Decision, keep.Deny) + } + + // Non-matching body content => allow. Without this, a rule that denied + // regardless of body would still pass the deny assertion above. + allowCall := keep.NewHTTPCallWithBody("POST", "api.openai.com", "/v1/chat/completions", + map[string]any{"model": "gpt-3.5-turbo"}) + result, err = eng.Evaluate(context.Background(), allowCall, "openai-gateway") + if err != nil { + t.Fatalf("Evaluate() error: %v", err) + } + if result.Decision != keep.Allow { + t.Errorf("Decision = %q, want %q", result.Decision, keep.Allow) + } +} + func TestRuleSet_EmptyCompiles(t *testing.T) { rs := keep.NewRuleSet("empty", "enforce") eng, err := rs.Compile()