feat(engine): inspect request body in policy rules#7
Conversation
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 <noreply@anthropic.com>
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 3m 14s —— View job Code Review
SummaryThe design is clean and well-motivated. The CEL AST walk is the right approach — computing param references at compile time rather than runtime avoids any overhead on the hot evaluation path, and using Three findings, one of which is worth fixing before merge: 1. The PR description and docstring both say the answer is "fixed at compile time" and a "cheap signal", but the implementation iterates all compiled rules on every call. For a gatekeeper checking this on each incoming request, that's a linear scan per request. The fix is trivial — precompute a 2. Nil body semantics should be documented (see inline comment) When 3. Optional-select coverage gap (low priority) (see inline comment)
Overall this is solid work — the only change I'd call necessary before merge is the O(1) precompute fix, since the current implementation silently contradicts its own documented guarantee. |
| } | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
The comment says "fixed at compile time" and the PR description describes this as a "cheap signal", but RequiresBody currently iterates all rules on every call — it's O(n) per call, not O(1). For a gatekeeper checking this on every incoming request, that's a linear scan per request instead of a map lookup.
Since the answer genuinely is fixed at compile time, it should be precomputed. For example, store a requiresBody bool field on Evaluator and set it in NewEvaluator once all rules are compiled:
// in NewEvaluator, after the rules loop:
requiresBody := false
for _, cr := range compiled {
if cr.program.ReferencesParam("body") {
requiresBody = true
break
}
}
// then: ev.requiresBody = requiresBody
func (ev *Evaluator) RequiresBody() bool {
return ev.requiresBody
}This also lets the docstring accurately say "O(1) lookup".
| func NewHTTPCallWithBody(method, host, path string, body map[string]any) Call { | ||
| call := NewHTTPCall(method, host, path) | ||
| call.Params["body"] = body | ||
| return call |
There was a problem hiding this comment.
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.
| } | ||
| case celast.CallKind: | ||
| call := e.AsCall() | ||
| if call.FunctionName() != operators.Index && call.FunctionName() != operators.OptIndex { |
There was a problem hiding this comment.
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.
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 4m 22s —— View job Code Review
SummaryThe opaque-access fail-safe (introduced in the latest commits) is well-designed and well-tested — detecting Two of the three findings from the previous review remain open. 1. O(n) scan — still not fixed (see inline comment)
2. The 3. Optional dot-select test still missing (see inline comment) — minor
|
| } | ||
| } | ||
| return false | ||
| } |
There was a problem hiding this comment.
Still O(n) per call — iterates all rules on every RequiresBody() invocation. The docstring (and keep.go's doc + the guide) all say the answer is "fixed at compile time", but the implementation doesn't precompute it.
For a gatekeeper calling engine.RequiresBody(scope) on every incoming request, this is a linear scan over all rules every time. The fix is trivial: add a requiresBody bool field to Evaluator, set it once in NewEvaluator after the rules loop, and return it directly:
// in NewEvaluator, after `compiled` is fully built:
requiresBody := false
for _, cr := range compiled {
if cr.program.ReferencesParam(ParamBody) {
requiresBody = true
break
}
}
// store on the struct, then:
func (ev *Evaluator) RequiresBody() bool {
return ev.requiresBody
}This was flagged in the previous review (#discussion_r3438035577) but wasn't addressed in the follow-up commits. The commit message fix(cel): fail safe on opaque params access; resolve review findings suggests it was meant to be fixed.
There was a problem hiding this comment.
Fixed in d920432 (pushed just after this review run): `RequiresBody` is now precomputed once in `NewEvaluator` via `computeRequiresBody` and stored on the `Evaluator`, so it is an O(1) field read per request. Docstrings updated to say so.
| func NewHTTPCallWithBody(method, host, path string, body any) Call { | ||
| call := NewHTTPCall(method, host, path) | ||
| call.Params[engine.ParamBody] = body | ||
| return call |
There was a problem hiding this comment.
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:
| 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. |
There was a problem hiding this comment.
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.
| t.Errorf("ReferencesParam(%q) on %q = %v, want %v", tt.field, tt.expr, got, tt.want) | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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 <noreply@anthropic.com>
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 4m 20s —— View job Code Review
SummaryAll three findings from the previous rounds are now resolved. The O(1) precompute, the nil-body doc fix, and the removal of the misleading Two remaining issues, one of which is worth fixing before merge: 1. Doc middleware example silently drops non-object JSON bodies — fix before merge In the new var body map[string]any
if engine.RequiresBody(scope) {
raw, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(raw))
_ = json.Unmarshal(raw, &body)
}
call := keep.NewHTTPCallWithBody(r.Method, r.Host, r.URL.Path, body)If the request body is a JSON array (e.g. a batch endpoint sending The docstring already says var body any
if engine.RequiresBody(scope) {
raw, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(raw))
_ = json.Unmarshal(raw, &body)
}
call := keep.NewHTTPCallWithBody(r.Method, r.Host, r.URL.Path, body)2.
{"optional dot select", "params?.body == 'x'", "body", true},What was fixed (confirmed)
|
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 <noreply@anthropic.com>
📦 Previous Review (superseded by newer review)Claude finished @dpup's task in 4m 42s —— View job Code Review
SummaryAll three findings from the previous reviews are properly resolved:
The core implementation is solid throughout:
One finding worth fixing before merge:
The code snippet is: var body map[string]any
if engine.RequiresBody(scope) {
raw, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(raw))
_ = json.Unmarshal(raw, &body)
}
call := keep.NewHTTPCallWithBody(r.Method, r.Host, r.URL.Path, body)
var body any
if engine.RequiresBody(scope) {
raw, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(raw))
_ = json.Unmarshal(raw, &body)
}
call := keep.NewHTTPCallWithBody(r.Method, r.Host, r.URL.Path, body)Otherwise this is ready to merge. |
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 <noreply@anthropic.com>
|
Claude finished @dpup's task in 3m 50s —— View job Code Review
SummaryAll three findings from the previous review rounds are resolved. The implementation is solid throughout. Previous findings — confirmed fixed
Core implementation — verified correct
One minor finding
|
Promotes the `[Unreleased]` section to `[0.5.0] - 2026-06-18` ahead of tagging v0.5.0 (minor bump from v0.4.0 for the request-body inspection feature, #7). Per the repo release convention: CHANGELOG commit → tag → push together. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
Lets Keep policy rules match on the parsed HTTP request body (
params.body), and gives gatekeepers a cheap, compile-time signal for whether buffering the body is even necessary for a given scope.Motivation: a gatekeeper sitting in front of an LLM/API gateway wants to apply rules like
params.body.model == 'gpt-4'orhasSecrets(params.body.prompt)— but buffering and parsing request bodies is expensive, so it should only do so when some rule in the active scope actually inspects the body.What's included
NewHTTPCallWithBody(method, host, path, body)(helpers.go) — builds on the existingNewHTTPCalland additionally setsparams.body.NewHTTPCallis left untouched for back-compat.Engine.RequiresBody(scope) bool(keep.go) — the trigger signal a gatekeeper reads. Returnsfalsefor an unknown scope.Programrecords the set of top-levelparams.*fields it references, computed once by walking the CEL AST. Exposed as the generalizedProgram.ReferencesParam(field);RequiresBodyis built onReferencesParam("body"). Detection handles:params.bodyparams["body"]params.body.model,params.body[0],hasSecrets(params.body.x),has(params.body)CEL eval, nested navigation,
hasSecrets, and redaction already operate onparams.bodyfor free — confirmed by an end-to-end test that denies agpt-4request.Testing
internal/cel/refs_test.go— table-driven coverage ofReferencesParam(dot/index/nested/negative cases, multiple fields, nil program).internal/engine/requires_body_test.go—Evaluator.RequiresBodyacross rule shapes, including alias indirection.keep_test.go—Engine.RequiresBody(positive, no-body, unknown scope) plus an end-to-end deny viaNewHTTPCallWithBody.helpers_test.go—NewHTTPCallWithBodyincluding nil-body handling.make test-unit(race),make lint,go vet, andgofmtall pass.🤖 Generated with Claude Code