Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions docs/content/guides/06-go-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
dpup marked this conversation as resolved.

### 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:
Expand Down
22 changes: 22 additions & 0 deletions helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing a nil body sets params["body"] = nil (typed nil map). CEL expressions like params.body.model == 'gpt-4' will then fail with a "no such key" or nil-navigation error, which the engine suppresses to false — so the rule won't match. This is likely the desired behaviour but it's a silent semantic difference from body being absent entirely (i.e. calling NewHTTPCall).

Worth adding a sentence to the doc comment that makes this explicit: "Passing a nil body means body-inspection rules will not match, since CEL treats nil as a missing value." That way callers understand the consequence of buffering but finding no body to parse.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The size(params.body) suggestion is inaccurate as a nil-guard. size(nil) produces a CEL type-error (swallowed to false), so size(params.body) > 0 would happen to return false for a nil body, but for the wrong reason — it's not a presence test, it's a size test that errors. It also returns false for an empty-but-present body ({}), which is a different semantic.

The nil-guard advice should just be params.body != null. Suggest updating the docstring:

Suggested change
return 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 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 5db6a91. The inaccurate `size(params.body)` hint is gone; the doc now recommends `params.body != null`. Verified: with a nil body it evaluates false, with a populated body true, and with an absent body false.

}

// 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.
Expand Down
54 changes: 54 additions & 0 deletions helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
93 changes: 92 additions & 1 deletion internal/cel/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: CEL also has an optional-chaining select (params?.body, represented as SelectKind with optional=true in some CEL versions, or as a distinct OptSelectKind). If your CEL version exposes SelectKind for optional selects too, the current check already covers it. If it uses a separate kind, it would be silently missed. Worth verifying, or at minimum adding a test case {"optional select", "params?.body == 'x'", "body", true} — if it fails you'll know there's a gap to fill.

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.
Expand Down
116 changes: 116 additions & 0 deletions internal/cel/refs_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous review flagged optional dot-select (params?.body) as an uncovered case. operators.OptIndex is now handled for the index form (params?["body"]), but there's still no test for the dot-select form. Worth adding one case to confirm coverage (or document if it's deliberately excluded):

{"optional dot select", "params?.body == 'x'", "body", true},

If CEL represents params?.body via SelectKind (with an optional flag), the existing SelectKind handler covers it and the test will pass. If it's a different AST kind, the test will fail and reveal the gap. Either way, the test documents the behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional dot-select (params?.body) and optional index (params[?"body"]) do not compile in Keep's CEL env — OptionalTypes is not enabled, so both are syntax errors and can never reach the walker. Adding the suggested test would fail at Compile, not at detection. The OptIndex branch is kept as future-proofing, and if OptionalTypes is ever enabled the new fail-safe opaque path (any unrecognized use of params ⇒ ReferencesParam returns true) covers these forms regardless.

}

// 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")
}
}
Loading
Loading