From 580f23e07818fa1e13b920f3bede504794e5245d Mon Sep 17 00:00:00 2001 From: Antoine Pietri Date: Sat, 9 May 2026 01:21:35 +0200 Subject: [PATCH] Fix type variable leakage in block variables list (issue #1312) Under the hood, when you compile a CEL policy with local variables, the compiler bundles them into a single internal function call called cel.@block. The first argument of this call is a list containing all the variable initializers, and the second is the body of the expression: cel.@block([var1_init, var2_init, ...], body) For example, if you define two variables: empty_list = [] // (inferred as list(dyn)) string_list = ["foo"] // (inferred as list(string)) They get bundled into: cel.@block([[], ["foo"]], ...) When CEL type-checks this composed expression, it looks at the first argument [[], ["foo"]] and sees a standard list literal. CEL lists are designed to be homogeneous. To enforce this, the type checker eagerly unifies the types of all elements in a list literal. However, when it unifies [] (which has an unbound type parameter list(_var0)) with ["foo"] (list(string)), it decides they are compatible by binding the type parameter _var0 to string. Even if the list as a whole later defaults to list(dyn) because of other incompatible elements, it's already too late: the type checker has already recorded that the independent empty_list is of type list(string) and will keep this information for the rest of the type checking step. If you later try to concatenate empty_list + [1], the type checker returns an error: > found no matching overload for '_+_' applied to '(list(string), list(int))' Basically, the type of string_list bled into the completely independent empty_list simply because they were temporarily bundled in the same initializer list. The problem is that the variables "list" passed to cel.@block is semantically not a normal homogeneous list. It is more like a heterogeneous tuple of independent expressions. To fix this, we special-case the internal cel.@block function in the core type checker. * When we encounter cel.@block, we type-check each variable initializer in the list independently. * We do not run the unification logic on them, completely preventing any type bleeding. * We set the type of the variables list directly to list(dyn) (matching the cel.@block signature) and then proceed to type-check the body. This allows local variables to maintain their independent types, resolving the compilation failures while preserving the performance benefits of cel.@block. --- checker/checker.go | 30 +++++++++++++++++++ policy/helper_test.go | 9 ++++++ .../config.yaml | 1 + .../policy.yaml | 9 ++++++ .../tests.yaml | 8 +++++ 5 files changed, 57 insertions(+) create mode 100644 policy/testdata/empty_list_unification_issue1312/config.yaml create mode 100644 policy/testdata/empty_list_unification_issue1312/policy.yaml create mode 100644 policy/testdata/empty_list_unification_issue1312/tests.yaml diff --git a/checker/checker.go b/checker/checker.go index 42d27a428..055b5d1a0 100644 --- a/checker/checker.go +++ b/checker/checker.go @@ -266,6 +266,10 @@ func (c *checker) checkCall(e ast.Expr) { c.checkOptSelect(e) return } + if fnName == "cel.@block" { + c.checkCelBlock(e) + return + } args := call.Args() // Traverse arguments. @@ -771,3 +775,29 @@ var ( types.UintKind: "google.protobuf.UInt64Value", } ) + +func (c *checker) checkCelBlock(e ast.Expr) { + call := e.AsCall() + if len(call.Args()) != 2 { + c.errors.unexpectedASTType(e.ID(), c.location(e), "cel.@block", "incorrect argument count") + c.setType(e, types.ErrorType) + return + } + + varsList := call.Args()[0] + if varsList.Kind() != ast.ListKind { + c.check(varsList) + } else { + create := varsList.AsList() + for _, elem := range create.Elements() { + c.check(elem) + } + c.setType(varsList, types.NewListType(types.DynType)) + } + + body := call.Args()[1] + c.check(body) + c.setType(e, c.getType(body)) + + c.setReference(e, ast.NewFunctionReference("cel_block_list")) +} diff --git a/policy/helper_test.go b/policy/helper_test.go index e11b06eac..b3c72beff 100644 --- a/policy/helper_test.go +++ b/policy/helper_test.go @@ -210,6 +210,15 @@ var ( : optional.none()))) : optional.of(@index3.format([@index0, @index2])))`, }, + { + name: "empty_list_unification_issue1312", + expr: ` + cel.@block([ + [], + ["foo"]], + @index0 + [1]) + `, + }, } composerUnnestTests = []struct { diff --git a/policy/testdata/empty_list_unification_issue1312/config.yaml b/policy/testdata/empty_list_unification_issue1312/config.yaml new file mode 100644 index 000000000..3b8de9b41 --- /dev/null +++ b/policy/testdata/empty_list_unification_issue1312/config.yaml @@ -0,0 +1 @@ +name: "empty_list_unification_issue1312" diff --git a/policy/testdata/empty_list_unification_issue1312/policy.yaml b/policy/testdata/empty_list_unification_issue1312/policy.yaml new file mode 100644 index 000000000..fe447e051 --- /dev/null +++ b/policy/testdata/empty_list_unification_issue1312/policy.yaml @@ -0,0 +1,9 @@ +name: "empty_list_unification_issue1312" +rule: + variables: + - name: "empty_list" + expression: "[]" + - name: "string_list" + expression: "['foo']" + match: + - output: "variables.empty_list + [1]" diff --git a/policy/testdata/empty_list_unification_issue1312/tests.yaml b/policy/testdata/empty_list_unification_issue1312/tests.yaml new file mode 100644 index 000000000..9cf292626 --- /dev/null +++ b/policy/testdata/empty_list_unification_issue1312/tests.yaml @@ -0,0 +1,8 @@ +description: Issue 1312 simple test +section: + - name: "basic" + tests: + - name: "test" + input: {} + output: + expr: "[1]"