From b57f87b50e14b03bd61b33686a922804465f7ab2 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Thu, 18 Jun 2026 18:17:13 +0000 Subject: [PATCH 1/6] feat(engine): inspect request body in policy rules Expose the parsed HTTP request body to rules under `params.body` and let gatekeepers cheaply detect, at compile time, whether a scope needs it. - helpers: add `NewHTTPCallWithBody(method, host, path, body)` which builds on `NewHTTPCall` and sets `params.body`. The existing `NewHTTPCall` is untouched for back-compat. - cel: compute the set of top-level `params.*` fields referenced by each expression at compile time (walking the CEL AST, handling both `params.body` selection and `params["body"]` indexing, including nested access). Expose via `Program.ReferencesParam(field)`. - engine: add `Evaluator.RequiresBody()` and `Engine.RequiresBody(scope)` as the trigger signal for buffering the body. Unknown scopes return false. CEL eval, nested navigation, hasSecrets, and redaction already operate on `params.body` for free, verified by an end-to-end test. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 +++ helpers.go | 12 ++++ helpers_test.go | 38 ++++++++++++ internal/cel/env.go | 60 +++++++++++++++++- internal/cel/refs_test.go | 63 +++++++++++++++++++ internal/engine/eval.go | 13 ++++ internal/engine/requires_body_test.go | 88 +++++++++++++++++++++++++++ keep.go | 14 +++++ keep_test.go | 82 +++++++++++++++++++++++++ 9 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 internal/cel/refs_test.go create mode 100644 internal/engine/requires_body_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e539ccd..aaf6e68 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 parsed 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)`). 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. Returns `false` for an unknown scope. + ## [0.4.0] - 2026-05-11 ### Added diff --git a/helpers.go b/helpers.go index 96ae73c..226de4f 100644 --- a/helpers.go +++ b/helpers.go @@ -41,6 +41,18 @@ 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 but additionally exposes the body to rules that inspect it (e.g. +// `params.body.model == 'gpt-4'` or `hasSecrets(params.body.prompt)`). +// The body is set as-is, including a nil body. Use Engine.RequiresBody to decide +// whether buffering and parsing the body is worthwhile before calling this. +func NewHTTPCallWithBody(method, host, path string, body map[string]any) Call { + call := NewHTTPCall(method, host, path) + call.Params["body"] = 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..f3a30fe 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -37,6 +37,44 @@ 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 body, _ := call.Params["body"].(map[string]any); body != nil { + t.Errorf("Params[body] = %v, want a nil map", body) + } +} + 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..46ba8fb 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,9 @@ 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 } // Compile parses and type-checks a CEL expression string. @@ -290,7 +295,60 @@ 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 + return &Program{prog: prog, paramRefs: collectParamRefs(ast.NativeRep())}, nil +} + +// ReferencesParam reports whether the compiled expression reads the given +// field off the params input variable, via either dot-selection (params.body) +// or index access (params["body"]). The result is computed at compile time, so +// this is a cheap lookup. Nested access such as params.body.foo or +// params.body[0] also counts as a reference to "body". +func (p *Program) ReferencesParam(field string) bool { + if p == nil { + return false + } + return p.paramRefs[field] +} + +// collectParamRefs walks a compiled AST and returns the set of top-level fields +// read off the params input variable. It recognizes both dot-selection +// (params.body) and index access with a string literal (params["body"]). +// Returns nil when no params fields are referenced. +func collectParamRefs(a *celast.AST) map[string]bool { + if a == nil { + return nil + } + refs := map[string]bool{} + celast.PostOrderVisit(a.Expr(), celast.NewExprVisitor(func(e celast.Expr) { + switch e.Kind() { + case celast.SelectKind: + sel := e.AsSelect() + if isParamsIdent(sel.Operand()) { + refs[sel.FieldName()] = true + } + 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 + } + } + })) + if len(refs) == 0 { + return nil + } + return refs +} + +// 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..6f32611 --- /dev/null +++ b/internal/cel/refs_test.go @@ -0,0 +1,63 @@ +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}, + {"dynamic index not literal", "params[params.method] == 'x'", "body", false}, + } + + 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) + } + }) + } +} + +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") + } +} + +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..2db5df2 100644 --- a/internal/engine/eval.go +++ b/internal/engine/eval.go @@ -138,6 +138,19 @@ func (ev *Evaluator) SetJudgeFunc(fn JudgeHandler) { ev.judgeFunc = fn } +// 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. +func (ev *Evaluator) RequiresBody() bool { + for _, cr := range ev.rules { + if cr.program.ReferencesParam("body") { + 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. diff --git a/internal/engine/requires_body_test.go b/internal/engine/requires_body_test.go new file mode 100644 index 0000000..5b0a3d3 --- /dev/null +++ b/internal/engine/requires_body_test.go @@ -0,0 +1,88 @@ +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, + }, + } + + 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..ac45a8e 100644 --- a/keep.go +++ b/keep.go @@ -163,6 +163,20 @@ 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. +// Returns false for an unknown scope. +func (e *Engine) RequiresBody(scope string) bool { + e.mu.RLock() + ev, ok := e.evaluators[scope] + e.mu.RUnlock() + if !ok { + return false + } + 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..b2ec8bc 100644 --- a/keep_test.go +++ b/keep_test.go @@ -917,6 +917,88 @@ 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 returns false rather than erroring. + if eng.RequiresBody("does-not-exist") { + t.Error("RequiresBody(does-not-exist) = true, want false") + } +} + +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") + } + + call := keep.NewHTTPCallWithBody("POST", "api.openai.com", "/v1/chat/completions", + map[string]any{"model": "gpt-4"}) + result, err := eng.Evaluate(context.Background(), call, "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) + } +} + func TestRuleSet_EmptyCompiles(t *testing.T) { rs := keep.NewRuleSet("empty", "enforce") eng, err := rs.Compile() From f355e81d469a550aecfa0c11b9b9d0c7fc0f32ed Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Thu, 18 Jun 2026 19:07:13 +0000 Subject: [PATCH 2/6] fix(review): widen body type, document detection limits, harden tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply code-review findings on the request-body inspection change: - helpers: accept `body any` in NewHTTPCallWithBody instead of `map[string]any` so JSON-array and scalar bodies are representable (forward-compatible; the old map-only signature would be a breaking change to fix post-release). Add a non-object-body test. - cel: document that collectParamRefs is a syntactic matcher — it covers every idiomatic body reference (dot, literal index, has/exists/map/ filter, `in`, ternary) but not computed-key or whole-map access, and note the cel-go/common/ast coupling for upgrade audits. - keep: add the ALLOW-path assertion to the end-to-end RequiresBody test so a rule that denied regardless of body content can't pass silently. Co-Authored-By: Claude Opus 4.8 --- helpers.go | 13 +++++++++---- helpers_test.go | 20 ++++++++++++++++++-- internal/cel/env.go | 16 +++++++++++++++- keep_test.go | 17 +++++++++++++++-- 4 files changed, 57 insertions(+), 9 deletions(-) diff --git a/helpers.go b/helpers.go index 226de4f..2b19079 100644 --- a/helpers.go +++ b/helpers.go @@ -43,11 +43,16 @@ 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 but additionally exposes the body to rules that inspect it (e.g. -// `params.body.model == 'gpt-4'` or `hasSecrets(params.body.prompt)`). -// The body is set as-is, including a nil body. Use Engine.RequiresBody to decide +// 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). Use Engine.RequiresBody to decide // whether buffering and parsing the body is worthwhile before calling this. -func NewHTTPCallWithBody(method, host, path string, body map[string]any) Call { +func NewHTTPCallWithBody(method, host, path string, body any) Call { call := NewHTTPCall(method, host, path) call.Params["body"] = body return call diff --git a/helpers_test.go b/helpers_test.go index f3a30fe..0120722 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -70,8 +70,24 @@ func TestNewHTTPCallWithBody_NilBody(t *testing.T) { if _, present := call.Params["body"]; !present { t.Error("Params[body] key should be present even for a nil body") } - if body, _ := call.Params["body"].(map[string]any); body != nil { - t.Errorf("Params[body] = %v, want a nil map", 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)) } } diff --git a/internal/cel/env.go b/internal/cel/env.go index 46ba8fb..b147b4b 100644 --- a/internal/cel/env.go +++ b/internal/cel/env.go @@ -312,8 +312,22 @@ func (p *Program) ReferencesParam(field string) bool { // collectParamRefs walks a compiled AST and returns the set of top-level fields // read off the params input variable. It recognizes both dot-selection -// (params.body) and index access with a string literal (params["body"]). +// (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. // Returns nil when no params fields are referenced. +// +// This is a purely syntactic matcher, so it does NOT see fields read through +// non-idiomatic forms: a computed index key (params["bo"+"dy"]), the whole +// params map used as a value (size(params), dyn(params).body), or a field +// reached after rebinding params to a comprehension variable. Such expressions +// report no reference even though they read the field. Callers that use the +// result as a fail-safe trigger (see Engine.RequiresBody) should therefore +// steer rule authors toward the idiomatic params. / params[""] +// access patterns. +// +// Coupled to cel-go/common/ast (PostOrderVisit, SelectKind/CallKind, the index +// operators); audit this walk when upgrading cel-go. func collectParamRefs(a *celast.AST) map[string]bool { if a == nil { return nil diff --git a/keep_test.go b/keep_test.go index b2ec8bc..5732ba9 100644 --- a/keep_test.go +++ b/keep_test.go @@ -988,15 +988,28 @@ rules: t.Fatal("expected scope to require body") } - call := keep.NewHTTPCallWithBody("POST", "api.openai.com", "/v1/chat/completions", + // 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(), call, "openai-gateway") + 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) { From 00baf08e00bca6652e37a126e5b6ab4d76c3bb77 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Thu, 18 Jun 2026 20:19:40 +0000 Subject: [PATCH 3/6] fix(cel): fail safe on opaque params access; resolve review findings Address the remaining code-review findings on request-body inspection: - cel: make body detection fail SAFE. collectParamRefs now tracks whether the params map is used in any form other than a recognized field access (computed index key, whole-map ops like size(params), dyn(params).body, comprehension rebind). When it is, ReferencesParam returns true for every field, so a gatekeeper buffers the body rather than silently skipping a rule it cannot statically resolve. Idiomatic single-field rules still do not over-trigger. - keep: Engine.RequiresBody now fails safe for an unknown scope (returns true), consistent with the detection principle; Evaluate still surfaces the misconfiguration as an error. - engine: introduce engine.ParamBody const as the single source of truth for the "body" params key, shared by the helper and the analysis. - docs: document NewHTTPCall/NewHTTPCallWithBody/NewMCPCall and the RequiresBody guard pattern in the Go library guide, and clarify that the built-in LLM gateway uses semantic decomposition rather than params.body. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 +- docs/content/guides/06-go-library.md | 45 +++++++++++++++++ helpers.go | 8 ++-- internal/cel/env.go | 69 +++++++++++++++++---------- internal/cel/refs_test.go | 20 +++++++- internal/engine/eval.go | 7 ++- internal/engine/requires_body_test.go | 8 ++++ keep.go | 8 +++- keep_test.go | 7 +-- 9 files changed, 139 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf6e68..fe378ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Request body inspection helpers** for HTTP policy evaluation - - `NewHTTPCallWithBody(method, host, path, body)` constructs a `Call` with the parsed 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)`). 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. Returns `false` for an unknown scope. + - `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 diff --git a/docs/content/guides/06-go-library.md b/docs/content/guides/06-go-library.md index b26cc20..343d4fb 100644 --- a/docs/content/guides/06-go-library.md +++ b/docs/content/guides/06-go-library.md @@ -70,6 +70,51 @@ 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 map[string]any + if engine.RequiresBody(scope) { + raw, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(raw)) // restore for the proxy + _ = json.Unmarshal(raw, &body) + } + 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 2b19079..32fb01a 100644 --- a/helpers.go +++ b/helpers.go @@ -50,11 +50,13 @@ func NewHTTPCall(method, host, path string) Call { // // 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). Use Engine.RequiresBody to decide -// whether buffering and parsing the body is worthwhile before calling this. +// body still sets params.body (to nil) — meaning has(params.body) is always +// true for calls built with this helper; test content with params.body != null +// or size(params.body) 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["body"] = body + call.Params[engine.ParamBody] = body return call } diff --git a/internal/cel/env.go b/internal/cel/env.go index b147b4b..1c93df3 100644 --- a/internal/cel/env.go +++ b/internal/cel/env.go @@ -282,6 +282,10 @@ type Program struct { // 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. @@ -295,50 +299,63 @@ 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, paramRefs: collectParamRefs(ast.NativeRep())}, nil + refs, opaque := collectParamRefs(ast.NativeRep()) + return &Program{prog: prog, paramRefs: refs, paramsOpaque: opaque}, nil } -// ReferencesParam reports whether the compiled expression reads the given -// field off the params input variable, via either dot-selection (params.body) -// or index access (params["body"]). The result is computed at compile time, so -// this is a cheap lookup. Nested access such as params.body.foo or -// params.body[0] also counts as a reference to "body". +// 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.paramRefs[field] + 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. 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. -// Returns nil when no params fields are referenced. +// 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. // -// This is a purely syntactic matcher, so it does NOT see fields read through -// non-idiomatic forms: a computed index key (params["bo"+"dy"]), the whole -// params map used as a value (size(params), dyn(params).body), or a field -// reached after rebinding params to a comprehension variable. Such expressions -// report no reference even though they read the field. Callers that use the -// result as a fail-safe trigger (see Engine.RequiresBody) should therefore -// steer rule authors toward the idiomatic params. / params[""] -// access patterns. +// 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) map[string]bool { +func collectParamRefs(a *celast.AST) (refs map[string]bool, opaque bool) { if a == nil { - return nil + return nil, false } - refs := map[string]bool{} + 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 e.AsIdent() == "params" { + total++ + } case celast.SelectKind: sel := e.AsSelect() if isParamsIdent(sel.Operand()) { refs[sel.FieldName()] = true + consumed++ } case celast.CallKind: call := e.AsCall() @@ -351,13 +368,15 @@ func collectParamRefs(a *celast.AST) map[string]bool { } if s, ok := args[1].AsLiteral().Value().(string); ok { refs[s] = true + consumed++ } } })) + opaque = total > consumed if len(refs) == 0 { - return nil + refs = nil } - return refs + return refs, opaque } // isParamsIdent reports whether e is the bare params input identifier. diff --git a/internal/cel/refs_test.go b/internal/cel/refs_test.go index 6f32611..5a454c5 100644 --- a/internal/cel/refs_test.go +++ b/internal/cel/refs_test.go @@ -26,7 +26,13 @@ func TestReferencesParam(t *testing.T) { {"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}, - {"dynamic index not literal", "params[params.method] == '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 { @@ -39,6 +45,18 @@ func TestReferencesParam(t *testing.T) { } } +// 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'") diff --git a/internal/engine/eval.go b/internal/engine/eval.go index 2db5df2..f08a32e 100644 --- a/internal/engine/eval.go +++ b/internal/engine/eval.go @@ -138,13 +138,18 @@ 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. func (ev *Evaluator) RequiresBody() bool { for _, cr := range ev.rules { - if cr.program.ReferencesParam("body") { + if cr.program.ReferencesParam(ParamBody) { return true } } diff --git a/internal/engine/requires_body_test.go b/internal/engine/requires_body_test.go index 5b0a3d3..8d4e678 100644 --- a/internal/engine/requires_body_test.go +++ b/internal/engine/requires_body_test.go @@ -55,6 +55,14 @@ func TestEvaluator_RequiresBody(t *testing.T) { }, 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 { diff --git a/keep.go b/keep.go index ac45a8e..b822d29 100644 --- a/keep.go +++ b/keep.go @@ -166,13 +166,17 @@ func (e *Engine) Evaluate(ctx context.Context, call Call, scope string) (EvalRes // 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. -// Returns false for an unknown scope. +// +// 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 false + return true } return ev.RequiresBody() } diff --git a/keep_test.go b/keep_test.go index 5732ba9..ee6b85e 100644 --- a/keep_test.go +++ b/keep_test.go @@ -938,9 +938,10 @@ rules: if !eng.RequiresBody("openai-gateway") { t.Error("RequiresBody(openai-gateway) = false, want true") } - // Unknown scope returns false rather than erroring. - if eng.RequiresBody("does-not-exist") { - t.Error("RequiresBody(does-not-exist) = true, want false") + // 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)") } } From d920432bb79170a26254c03cdb9dfa3339cd1716 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Thu, 18 Jun 2026 20:25:20 +0000 Subject: [PATCH 4/6] perf(engine): precompute RequiresBody; clarify nil-body docs Address PR review feedback: - engine: precompute the body-reference scan once in NewEvaluator and store it on the Evaluator, so Evaluator.RequiresBody (and Engine.RequiresBody) is an O(1) lookup safe to call per request rather than an O(n-rules) scan on every call. - helpers: spell out in NewHTTPCallWithBody's doc that a nil body means content-inspection rules won't match (CEL resolves the nil navigation to false), while has(params.body) stays true. Optional-chaining selects (params?.body, params[?"body"]) were raised in review: they do not compile in Keep's CEL env (OptionalTypes is not enabled), and if ever enabled the fail-safe opaque path would cover them. Co-Authored-By: Claude Opus 4.8 --- helpers.go | 10 ++++++---- internal/engine/eval.go | 12 ++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/helpers.go b/helpers.go index 32fb01a..a5cf37f 100644 --- a/helpers.go +++ b/helpers.go @@ -50,10 +50,12 @@ func NewHTTPCall(method, host, path string) Call { // // 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) — meaning has(params.body) is always -// true for calls built with this helper; test content with params.body != null -// or size(params.body) instead. Use Engine.RequiresBody to decide whether -// buffering and parsing the body is worthwhile before calling this. +// 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. +// 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 diff --git a/internal/engine/eval.go b/internal/engine/eval.go index f08a32e..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. @@ -146,9 +147,15 @@ 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. +// 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 { - for _, cr := range ev.rules { + 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 } @@ -219,6 +226,7 @@ func NewEvaluator( scope: scope, secrets: detector, caseSensitive: caseSensitive, + requiresBody: computeRequiresBody(compiled), }, nil } From 5db6a910cf68b21113cafd3fe090a453b6a25326 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Thu, 18 Jun 2026 20:26:41 +0000 Subject: [PATCH 5/6] docs(helpers): recommend params.body != null as the nil-body guard Per PR review: the prior doc's size(params.body) hint was an inaccurate presence test (size(nil) errors, and an empty {} body is non-nil). Point callers at params.body != null, which correctly distinguishes a populated body (true) from a nil or absent body (false). Co-Authored-By: Claude Opus 4.8 --- helpers.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/helpers.go b/helpers.go index a5cf37f..04e6264 100644 --- a/helpers.go +++ b/helpers.go @@ -53,8 +53,9 @@ func NewHTTPCall(method, host, path string) Call { // 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. -// Use Engine.RequiresBody to decide whether buffering and parsing the body is +// 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) From 6cb54ae928735508b140c0cf94b733adc1c27397 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Thu, 18 Jun 2026 21:39:07 +0000 Subject: [PATCH 6/6] docs(guide): fix middleware example body type and error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review follow-ups before the next review round: - guide: the middleware snippet declared `var body map[string]any`, which silently drops top-level JSON array/scalar bodies (json.Unmarshal fails, error discarded, body stays nil) — contradicting the documented `any` support. Use `var body any` and handle the read/parse errors instead of discarding them. - cel: reuse isParamsIdent in the IdentKind branch of collectParamRefs so the "params" name check lives in one place. - cel: add TestParamsBodyNilSemantics pinning has(params.body)==true and params.body != null ==false for a nil body, guarding the helper docstring's runtime claim against a cel-go change. Co-Authored-By: Claude Opus 4.8 --- docs/content/guides/06-go-library.md | 15 +++++++++--- internal/cel/env.go | 2 +- internal/cel/refs_test.go | 35 ++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/content/guides/06-go-library.md b/docs/content/guides/06-go-library.md index 343d4fb..fa3c097 100644 --- a/docs/content/guides/06-go-library.md +++ b/docs/content/guides/06-go-library.md @@ -100,11 +100,20 @@ Buffering and parsing a request body is not free, so only do it when a rule in t ```go func middleware(engine *keep.Engine, scope string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var body map[string]any + var body any // any JSON shape: object, array, or scalar if engine.RequiresBody(scope) { - raw, _ := io.ReadAll(r.Body) + 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 - _ = json.Unmarshal(raw, &body) + 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 diff --git a/internal/cel/env.go b/internal/cel/env.go index 1c93df3..d896f19 100644 --- a/internal/cel/env.go +++ b/internal/cel/env.go @@ -348,7 +348,7 @@ func collectParamRefs(a *celast.AST) (refs map[string]bool, opaque bool) { celast.PostOrderVisit(a.Expr(), celast.NewExprVisitor(func(e celast.Expr) { switch e.Kind() { case celast.IdentKind: - if e.AsIdent() == "params" { + if isParamsIdent(e) { total++ } case celast.SelectKind: diff --git a/internal/cel/refs_test.go b/internal/cel/refs_test.go index 5a454c5..10966d5 100644 --- a/internal/cel/refs_test.go +++ b/internal/cel/refs_test.go @@ -71,6 +71,41 @@ func TestReferencesParam_MultipleFields(t *testing.T) { } } +// 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.