diff --git a/common/env/env_test.go b/common/env/env_test.go
index 866807ef1..b2c0047aa 100644
--- a/common/env/env_test.go
+++ b/common/env/env_test.go
@@ -1534,7 +1534,7 @@ func unmarshalYAML(t *testing.T, data []byte) *Config {
t.Helper()
config, err := ConfigFromYAML(data)
if err != nil {
- t.Fatalf("ConfigFromYaml(%q) failed: %v", string(data), err)
+ t.Fatalf("ConfigFromYAML(%q) failed: %v", string(data), err)
}
return config
}
diff --git a/policy/BUILD.bazel b/policy/BUILD.bazel
index d443c6d7c..60871012c 100644
--- a/policy/BUILD.bazel
+++ b/policy/BUILD.bazel
@@ -72,8 +72,9 @@ go_test(
"//test:go_default_library",
"//common/debug:go_default_library",
"//common/types:go_default_library",
- "//interpreter:go_default_library",
"//common/types/ref:go_default_library",
+ "//common/types/traits:go_default_library",
+ "//interpreter:go_default_library",
"//test/proto3pb:go_default_library",
"@in_yaml_go_yaml_v3//:go_default_library",
"@com_github_google_go_cmp//cmp:go_default_library",
diff --git a/policy/compiler.go b/policy/compiler.go
index 2ca84e0a2..d2a4212c1 100644
--- a/policy/compiler.go
+++ b/policy/compiler.go
@@ -34,6 +34,7 @@ type CompiledRule struct {
id *ValueString
variables []*CompiledVariable
matches []*CompiledMatch
+ semantic SemanticType
}
// SourceID returns the source metadata identifier associated with the compiled rule.
@@ -56,11 +57,21 @@ func (r *CompiledRule) Matches() []*CompiledMatch {
return r.matches[:]
}
+// Semantic returns the evaluation semantic for the compiled rule.
+func (r *CompiledRule) Semantic() SemanticType {
+ return r.semantic
+}
+
// OutputType returns the output type of the first match clause as all match clauses
// are validated for agreement prior to construction fo the CompiledRule.
func (r *CompiledRule) OutputType() *cel.Type {
// It's a compilation error if the output types of the matches don't agree
- for _, m := range r.Matches() {
+ matches := r.Matches()
+ if len(matches) > 0 {
+ m := matches[0]
+ if r.semantic == aggregate {
+ return cel.ListType(m.OutputType())
+ }
return m.OutputType()
}
return cel.DynType
@@ -69,6 +80,9 @@ func (r *CompiledRule) OutputType() *cel.Type {
// HasOptionalOutput returns whether the rule returns a concrete or optional value.
// The rule may return an optional value if all match expressions under the rule are conditional.
func (r *CompiledRule) HasOptionalOutput() bool {
+ if r.semantic == aggregate {
+ return false
+ }
optionalOutput := false
for _, m := range r.Matches() {
if m.NestedRule() != nil && m.NestedRule().HasOptionalOutput() {
@@ -297,7 +311,7 @@ func CompileRule(env *cel.Env, p *Policy, opts ...CompilerOption) (*CompiledRule
c.env = env
}
}
- return c.compileRule(p.Rule(), p, c.env, iss)
+ return c.compileRule(p.Rule(), p, c.env, iss, false)
}
type compiler struct {
@@ -310,7 +324,10 @@ type compiler struct {
nestedCount int
}
-func (c *compiler) compileRule(r *Rule, p *Policy, ruleEnv *cel.Env, iss *cel.Issues) (*CompiledRule, *cel.Issues) {
+func (c *compiler) compileRule(r *Rule, p *Policy, ruleEnv *cel.Env, iss *cel.Issues, hasAggregateAncestor bool) (*CompiledRule, *cel.Issues) {
+ if hasAggregateAncestor && r.semantic == aggregate {
+ iss.ReportErrorAtID(r.SourceID(), "nested aggregate rules are not allowed")
+ }
compiledVars := make([]*CompiledVariable, len(r.Variables()))
for i, v := range r.Variables() {
exprSrc := c.relSource(v.Expression())
@@ -379,7 +396,8 @@ func (c *compiler) compileRule(r *Rule, p *Policy, ruleEnv *cel.Env, iss *cel.Is
continue
}
if m.HasRule() {
- nestedRule, ruleIss := c.compileRule(m.Rule(), p, ruleEnv, iss)
+ nextHasAggregateAncestor := hasAggregateAncestor || r.semantic == aggregate
+ nestedRule, ruleIss := c.compileRule(m.Rule(), p, ruleEnv, iss, nextHasAggregateAncestor)
iss = iss.Append(ruleIss)
compiledMatches = append(compiledMatches, &CompiledMatch{
exprID: m.exprID,
@@ -401,6 +419,7 @@ func (c *compiler) compileRule(r *Rule, p *Policy, ruleEnv *cel.Env, iss *cel.Is
id: r.id,
variables: compiledVars,
matches: compiledMatches,
+ semantic: r.semantic,
}
// Note: Consider supporting configurable policy validators that take the policy, rule, and issues
@@ -453,10 +472,14 @@ func (c *compiler) checkUnreachableCode(rule *CompiledRule, iss *cel.Issues) {
m := compiledMatches[i]
triviallyTrue := m.ConditionIsLiteral(types.True)
+ if m.ConditionIsLiteral(types.False) {
+ iss.ReportErrorAtID(m.SourceID(), "Condition is always false")
+ }
+
// If the match is a single output or a nested rule that always returns a value, it is
// exhaustive. If the condition is trivially true, then all subsequent branches are unreachable.
isExhaustive := triviallyTrue && (m.NestedRule() == nil || !m.NestedRule().HasOptionalOutput())
- if isExhaustive && i != matchCount-1 {
+ if rule.semantic == firstMatch && isExhaustive && i != matchCount-1 {
if m.Output() != nil {
iss.ReportErrorAtID(m.SourceID(), "match creates unreachable outputs")
}
diff --git a/policy/compiler_test.go b/policy/compiler_test.go
index 6f9bc4f3b..a1746a247 100644
--- a/policy/compiler_test.go
+++ b/policy/compiler_test.go
@@ -46,52 +46,6 @@ func TestCompile(t *testing.T) {
}
}
-func TestRuleComposerError(t *testing.T) {
- env, err := cel.NewEnv()
- if err != nil {
- t.Fatalf("NewEnv() failed: %v", err)
- }
- _, err = NewRuleComposer(env, ExpressionUnnestHeight(-1))
- if err == nil || !strings.Contains(err.Error(), "invalid unnest") {
- t.Errorf("NewRuleComposer() got %v, wanted 'invalid unnest'", err)
- }
-}
-
-func TestRuleComposerUnnest(t *testing.T) {
- for _, tst := range composerUnnestTests {
- tc := tst
- t.Run(tc.name, func(t *testing.T) {
- r := newRunner(tc.name, tc.expr, []ParserOption{})
- env, rule, iss := r.compileRule(t)
- if iss.Err() != nil {
- t.Fatalf("CompileRule() failed: %v", iss.Err())
- }
- rc, err := NewRuleComposer(env, tc.composerOpts...)
- if err != nil {
- t.Fatalf("NewRuleComposer() failed: %v", err)
- }
- ast, iss := rc.Compose(rule)
- if iss.Err() != nil {
- t.Fatalf("Compose(rule) failed: %v", iss.Err())
- }
- policy := parsePolicy(t, tc.name, []ParserOption{})
- verifySourceInfoCoverage(t, policy, ast)
- unparsed, err := cel.AstToString(ast)
- if err != nil {
- t.Fatalf("cel.AstToString() failed: %v", err)
- }
- if normalize(unparsed) != normalize(tc.composed) {
- t.Errorf("cel.AstToString() got %s, wanted %s", unparsed, tc.composed)
- }
- if !ast.OutputType().IsEquivalentType(tc.outputType) {
- t.Errorf("ast.OutputType() got %v, wanted %v", ast.OutputType(), tc.outputType)
- }
- r.setup(t, env, ast)
- r.run(t)
- })
- }
-}
-
func TestCompileError(t *testing.T) {
for _, tst := range policyErrorTests {
policy := parsePolicy(t, tst.name, []ParserOption{})
@@ -290,6 +244,36 @@ func BenchmarkCompile(b *testing.B) {
}
}
+func parsePolicySource(t testing.TB, name string, policySource string, parseOpts ...ParserOption) *Policy {
+ t.Helper()
+ p := StringSource(policySource, name)
+ parser, err := NewParser(parseOpts...)
+ if err != nil {
+ t.Fatalf("NewParser() failed: %v", err)
+ }
+ policy, iss := parser.Parse(p)
+ if iss.Err() != nil {
+ t.Fatalf("parser.Parse() failed: %v", iss.Err())
+ }
+ return policy
+}
+
+func parseAndCompilePolicy(t testing.TB, name string, policySource string, envOpts []cel.EnvOption, compilerOpts []CompilerOption) (*cel.Env, *cel.Ast, *cel.Issues) {
+ t.Helper()
+ policy := parsePolicySource(t, name, policySource)
+ envOpts = append([]cel.EnvOption{
+ cel.OptionalTypes(),
+ cel.EnableMacroCallTracking(),
+ ext.Bindings(),
+ }, envOpts...)
+ env, err := cel.NewEnv(envOpts...)
+ if err != nil {
+ t.Fatalf("cel.NewEnv() failed: %v", err)
+ }
+ ast, iss := Compile(env, policy, compilerOpts...)
+ return env, ast, iss
+}
+
func newRunner(name, expr string, parseOpts []ParserOption, opts ...cel.EnvOption) *runner {
return &runner{
name: name,
@@ -617,3 +601,238 @@ func exprLinesFromPolicy(policy *Policy) map[int]bool {
traverseRule(policy.Rule())
return lines
}
+
+func TestCompileYAMLPolicy_Aggregate(t *testing.T) {
+ type testEval struct {
+ input map[string]any
+ output ref.Val
+ }
+ tests := []struct {
+ name string
+ policy string
+ envOpts []cel.EnvOption
+ expectedUnparsed string
+ evals []testEval
+ wantErr string
+ }{
+ {
+ name: "eval_aggregate",
+ policy: `name: "aggregate_policy"
+rule:
+ aggregate:
+ - condition: 'true'
+ emit: '"PII"'
+ - condition: 'true'
+ emit: '"CONFIDENTIAL"'`,
+ expectedUnparsed: `["PII"] + ["CONFIDENTIAL"]`,
+ evals: []testEval{
+ {
+ input: map[string]any{},
+ output: types.NewStringList(types.DefaultTypeAdapter, []string{"PII", "CONFIDENTIAL"}),
+ },
+ },
+ },
+ {
+ name: "aggregate_with_block_variables",
+ policy: `name: "block_policy"
+rule:
+ variables:
+ - name: val1
+ expression: '"PII"'
+ - name: val2
+ expression: '"CONFIDENTIAL"'
+ aggregate:
+ - condition: 'true'
+ emit: 'variables.val1'
+ - condition: 'true'
+ emit: 'variables.val2'`,
+ expectedUnparsed: `cel.@block(["PII", "CONFIDENTIAL"], [@index0] + [@index1])`,
+ evals: []testEval{
+ {
+ input: map[string]any{},
+ output: types.NewStringList(types.DefaultTypeAdapter, []string{"PII", "CONFIDENTIAL"}),
+ },
+ },
+ },
+ {
+ name: "aggregate_conditions_and_block_variables",
+ policy: `name: "cse_policy"
+rule:
+ variables:
+ - name: threshold
+ expression: "5"
+ aggregate:
+ - condition: "size(resource.payload) > variables.threshold"
+ emit: '"CSE1"'
+ - condition: "size(resource.payload) > variables.threshold"
+ emit: '"CSE2"'
+ - condition: 'true'
+ emit: '"ALWAYS"'`,
+ envOpts: []cel.EnvOption{
+ cel.Variable("resource", cel.MapType(cel.StringType, cel.ListType(cel.IntType))),
+ },
+ expectedUnparsed: `cel.@block([5], ((size(resource.payload) > @index0) ? ["CSE1"] : []) + (((size(resource.payload) > @index0) ? ["CSE2"] : []) + ["ALWAYS"]))`,
+ evals: []testEval{
+ {
+ input: map[string]any{
+ "resource": map[string]any{
+ "payload": []int64{1, 2, 3, 4, 5, 6},
+ },
+ },
+ output: types.NewStringList(types.DefaultTypeAdapter, []string{"CSE1", "CSE2", "ALWAYS"}),
+ },
+ {
+ input: map[string]any{
+ "resource": map[string]any{
+ "payload": []int64{1, 2, 3},
+ },
+ },
+ output: types.NewStringList(types.DefaultTypeAdapter, []string{"ALWAYS"}),
+ },
+ },
+ },
+ {
+ name: "aggregate_macros_preserved",
+ policy: `name: aggregate_macros_preserved
+rule:
+ variables:
+ - name: min_val
+ expression: "10"
+ aggregate:
+ - condition: "cond"
+ rule:
+ match:
+ - condition: "true"
+ output: "payload.filter(x, x > variables.min_val).exists(y, y % 2 == 0)"
+ - condition: "true"
+ emit: "payload.all(x, x > 0)"`,
+ envOpts: []cel.EnvOption{
+ cel.Variable("cond", cel.BoolType),
+ cel.Variable("payload", cel.ListType(cel.IntType)),
+ },
+ expectedUnparsed: `cel.@block([10], (cond ? [payload.filter(x, x > @index0).exists(y, y % 2 == 0)] : []) + [payload.all(x, x > 0)])`,
+ },
+ {
+ name: "nested_aggregate_throws",
+ policy: `name: nested_aggregate
+rule:
+ aggregate:
+ - condition: 'true'
+ rule:
+ aggregate:
+ - condition: 'true'
+ emit: "'foo'"`,
+ wantErr: "nested aggregate rules are not allowed",
+ },
+ {
+ name: "nested_aggregate_with_match_throws",
+ policy: `name: nested_aggregate_with_match
+rule:
+ aggregate:
+ - condition: 'true'
+ rule:
+ match:
+ - condition: 'true'
+ rule:
+ aggregate:
+ - condition: 'true'
+ emit: "'foo'"`,
+ wantErr: "nested aggregate rules are not allowed",
+ },
+ {
+ name: "aggregate_under_match_success",
+ policy: `name: aggregate_under_match
+rule:
+ match:
+ - condition: 'true'
+ rule:
+ aggregate:
+ - condition: 'true'
+ emit: "'foo'"`,
+ expectedUnparsed: `["foo"]`,
+ },
+ }
+
+ for _, tst := range tests {
+ tc := tst
+ t.Run(tc.name, func(t *testing.T) {
+ env, ast, iss := parseAndCompilePolicy(t, tc.name, tc.policy, tc.envOpts, nil)
+ if tc.wantErr != "" {
+ if iss.Err() == nil {
+ t.Fatalf("Compile() succeeded, wanted error %q", tc.wantErr)
+ }
+ if !strings.Contains(iss.Err().Error(), tc.wantErr) {
+ t.Errorf("Compile() got %v, wanted error containing %q", iss.Err(), tc.wantErr)
+ }
+ return
+ }
+
+ if iss.Err() != nil {
+ t.Fatalf("Compile() failed: %v", iss.Err())
+ }
+
+ unparsed, err := cel.AstToString(ast)
+ if err != nil {
+ t.Fatalf("cel.AstToString() failed: %v", err)
+ }
+ if tc.expectedUnparsed != "" && normalize(unparsed) != normalize(tc.expectedUnparsed) {
+ t.Errorf("cel.AstToString() got %s, wanted %s", unparsed, tc.expectedUnparsed)
+ }
+
+ _, err = cel.AstToCheckedExpr(ast)
+ if err != nil {
+ t.Fatalf("cel.AstToCheckedExpr() failed: %v", err)
+ }
+
+ prg, err := env.Program(ast)
+ if err != nil {
+ t.Fatalf("env.Program(ast) failed: %v", err)
+ }
+
+ for _, ev := range tc.evals {
+ out, _, err := prg.Eval(ev.input)
+ if err != nil {
+ t.Fatalf("prg.Eval(%v) failed: %v", ev.input, err)
+ }
+ if out.Equal(ev.output) != types.True {
+ t.Errorf("prg.Eval(%v) got %v, wanted %v", ev.input, out, ev.output)
+ }
+ }
+ })
+ }
+}
+
+func TestCompiledRuleSemantic(t *testing.T) {
+ policySource := `name: aggregate_semantic
+rule:
+ aggregate:
+ - condition: 'true'
+ emit: "'foo'"`
+ policy := parsePolicySource(t, "aggregate_semantic", policySource)
+ env, err := cel.NewEnv()
+ if err != nil {
+ t.Fatalf("cel.NewEnv() failed: %v", err)
+ }
+ compiledRule, iss := CompileRule(env, policy)
+ if iss.Err() != nil {
+ t.Fatalf("CompileRule() failed: %v", iss.Err())
+ }
+ if compiledRule.Semantic() != aggregate {
+ t.Errorf("got %v, wanted aggregate", compiledRule.Semantic())
+ }
+}
+
+func TestCompileYAMLPolicy_ConditionAlwaysFalse(t *testing.T) {
+ policySource := `name: condition_always_false
+rule:
+ aggregate:
+ - condition: 'false'
+ emit: "'foo'"`
+ _, _, iss := parseAndCompilePolicy(t, "condition_always_false", policySource, nil, nil)
+ if iss.Err() == nil {
+ t.Fatalf("Compile() succeeded, wanted error")
+ }
+ if !strings.Contains(iss.Err().Error(), "Condition is always false") {
+ t.Errorf("Compile() got %v, wanted 'Condition is always false'", iss.Err())
+ }
+}
diff --git a/policy/composer.go b/policy/composer.go
index ef392184f..cf291c9a3 100644
--- a/policy/composer.go
+++ b/policy/composer.go
@@ -92,7 +92,7 @@ func (c *RuleComposer) Compose(r *CompiledRule) (*cel.Ast, *cel.Issues) {
return nil, iss
}
unnester := &ruleUnnesterImpl{
- nextVarIndex: len(composer.varIndices),
+ nextVarIndex: len(composer.varIndices),
varIndices: composer.varIndices,
exprUnnestHeight: c.exprUnnestHeight,
}
@@ -159,7 +159,7 @@ func (opt *ruleComposerImpl) exitScope() {
func (opt *ruleComposerImpl) Optimize(ctx *cel.OptimizerContext, a *ast.AST) *ast.AST {
// The input to optimize is a dummy expression which is completely replaced according
// to the configuration of the rule composition graph.
- ruleExpr := opt.optimizeRule(ctx, opt.rule)
+ ruleExpr := opt.optimizeRule(ctx, opt.rule, false)
// If there were no variables, return the expression.
if len(opt.varIndices) == 0 {
@@ -180,7 +180,7 @@ func (opt *ruleComposerImpl) Optimize(ctx *cel.OptimizerContext, a *ast.AST) *as
return ctx.NewAST(blockExpr)
}
-func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *CompiledRule) ast.Expr {
+func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *CompiledRule, isAggregateParent bool) ast.Expr {
// Visitor to rewrite variables-prefixed identifiers with index names.
opt.enterScope()
defer opt.exitScope()
@@ -189,13 +189,16 @@ func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *Compiled
opt.registerVariable(ctx, v)
}
+ isAggregate := r.semantic == aggregate
+ returnList := isAggregateParent || isAggregate
+
matches := r.Matches()
matchCount := len(matches)
var output compositionStep = nil
- // If the rule has an optional output, the last result in the ternary should return
+ // If the rule is non-aggregate and has an optional output, the last result in the ternary should return
// `optional.none`. This output is implicit and created here to reflect the desired
// last possible output of this type of rule.
- if r.HasOptionalOutput() {
+ if !returnList && r.HasOptionalOutput() {
output = newOptionalCompositionStep(ctx, ctx.NewLiteral(types.True), ctx.NewCall("optional.none"))
}
// Build the rule subgraph.
@@ -203,29 +206,51 @@ func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *Compiled
m := matches[i]
cond := ctx.CopyASTAndMetadata(m.Condition().NativeRep())
- // If the output is non-nil, then it is considered a non-optional output since
- // it is explictly stated. If the rule itself is optional, then the base case value
- // of output being optional.none() will convert the non-optional value to an optional
- // one.
+ var currentStep compositionStep
if m.Output() != nil {
+ // If the output is non-nil, then it is considered a non-optional output since
+ // it is explicitly stated. If the rule itself is optional, then the base case value
+ // of output being optional.none() will convert the non-optional value to an optional
+ // one.
out := ctx.CopyASTAndMetadata(m.Output().Expr().NativeRep())
- step := newNonOptionalCompositionStep(ctx, cond, out)
- output = step.combine(output)
- continue
+ if returnList {
+ outList := ctx.NewList([]ast.Expr{out}, []int32{})
+ currentStep = newNonOptionalCompositionStep(ctx, cond, outList)
+ } else {
+ currentStep = newNonOptionalCompositionStep(ctx, cond, out)
+ }
+ } else if m.NestedRule() != nil {
+ // If the match has a nested rule, then compute the rule and whether it has
+ // an optional return value.
+ //
+ // Semantics for nesting:
+ // - With optional values (nestedHasOptional = true): The step is treated as optional.
+ // If the nested rule yields optional.none, composition allows fall-through to
+ // subsequent match cases.
+ // - Without optional values (nestedHasOptional = false): The step is treated as non-optional.
+ // A matching result produces a concrete value that short-circuits further match evaluation,
+ // though it may be wrapped into optional.of(...) if the outer rule produces optional output.
+ child := m.NestedRule()
+ nestedRule := opt.optimizeRule(ctx, child, returnList)
+ nestedHasOptional := !returnList && child.HasOptionalOutput()
+ if nestedHasOptional {
+ currentStep = newOptionalCompositionStep(ctx, cond, nestedRule)
+ } else {
+ currentStep = newNonOptionalCompositionStep(ctx, cond, nestedRule)
+ }
}
- // If the match has a nested rule, then compute the rule and whether it has
- // an optional return value.
- child := m.NestedRule()
- nestedRule := opt.optimizeRule(ctx, child)
- nestedHasOptional := child.HasOptionalOutput()
- if nestedHasOptional {
- step := newOptionalCompositionStep(ctx, cond, nestedRule)
- output = step.combine(output)
- continue
+ if isAggregate {
+ output = opt.combineAggregate(ctx, currentStep, output)
+ } else {
+ output = currentStep.combine(output)
+ }
+ }
+
+ if output == nil {
+ if returnList {
+ output = newNonOptionalCompositionStep(ctx, ctx.NewLiteral(types.True), ctx.NewList([]ast.Expr{}, []int32{}))
}
- step := newNonOptionalCompositionStep(ctx, cond, nestedRule)
- output = step.combine(output)
}
matchExpr := output.expr()
@@ -235,6 +260,23 @@ func (opt *ruleComposerImpl) optimizeRule(ctx *cel.OptimizerContext, r *Compiled
return matchExpr
}
+func (opt *ruleComposerImpl) combineAggregate(ctx *cel.OptimizerContext, step, accumulatedStep compositionStep) compositionStep {
+ trueCondition := ctx.NewLiteral(types.True)
+ currentListPart := step.expr()
+ var conditionalListPart ast.Expr
+ if step.isConditional() {
+ emptyList := ctx.NewList([]ast.Expr{}, []int32{})
+ conditionalListPart = ctx.NewCall(operators.Conditional, step.condition(), currentListPart, emptyList)
+ } else {
+ conditionalListPart = currentListPart
+ }
+ if accumulatedStep == nil {
+ return newNonOptionalCompositionStep(ctx, trueCondition, conditionalListPart)
+ }
+ concatenated := ctx.NewCall(operators.Add, conditionalListPart, accumulatedStep.expr())
+ return newNonOptionalCompositionStep(ctx, trueCondition, concatenated)
+}
+
func (opt *ruleComposerImpl) rewriteVariableName(ctx *cel.OptimizerContext) ast.Visitor {
return ast.NewExprVisitor(func(expr ast.Expr) {
if expr.Kind() != ast.IdentKind || !strings.HasPrefix(expr.AsIdent(), "variables.") {
@@ -265,7 +307,7 @@ func (opt *ruleComposerImpl) registerVariable(ctx *cel.OptimizerContext, v *Comp
celType: v.Declaration().Type()}
opt.varIndices = append(opt.varIndices, vi)
if len(opt.scopes) > 0 {
- opt.scopes[len(opt.scopes) - 1][varName] = len(opt.varIndices) - 1
+ opt.scopes[len(opt.scopes)-1][varName] = len(opt.varIndices) - 1
}
opt.nextVarIndex++
}
@@ -494,12 +536,20 @@ func (s nonOptionalCompositionStep) combine(step compositionStep) compositionSte
// Likely a candidate for dead-code warnings.
return s
}
+ if !s.isConditional() {
+ return s
+ }
+ stepExpr := step.expr()
+ if step.isConditional() {
+ emptyList := ctx.NewList([]ast.Expr{}, []int32{})
+ stepExpr = ctx.NewCall(operators.Conditional, step.condition(), step.expr(), emptyList)
+ }
return newNonOptionalCompositionStep(ctx,
trueCondition,
ctx.NewCall(operators.Conditional,
s.condition(),
s.expr(),
- step.expr()))
+ stepExpr))
}
// newOptionalCompositionStep returns an output step with an optional policy output.
@@ -587,9 +637,7 @@ func isOptionalNone(e ast.Expr) bool {
func removeIneligibleSubExprs(e ast.NavigableExpr, unnestMap map[int64]bool) {
for _, id := range comprehensionSubExprIDs(e) {
- if _, found := unnestMap[id]; found {
- delete(unnestMap, id)
- }
+ delete(unnestMap, id)
}
}
diff --git a/policy/composer_test.go b/policy/composer_test.go
index 5cd601c2c..5254066ec 100644
--- a/policy/composer_test.go
+++ b/policy/composer_test.go
@@ -1,97 +1,204 @@
package policy
import (
+ "fmt"
"strings"
"testing"
"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/ast"
"github.com/google/cel-go/common/debug"
+ "github.com/google/cel-go/common/types"
"github.com/google/cel-go/ext"
)
-func TestCompose_SourceInfo(t *testing.T) {
- policyYAML := `name: test_policy
+func TestCompose(t *testing.T) {
+ tests := []struct {
+ name string
+ policy string
+ composerOpts []ComposerOption
+ wantUnparsed string
+ wantEval string
+ checkInfo bool
+ }{
+ {
+ name: "source_info",
+ policy: `name: test_policy
rule:
match:
- condition: "2 == 1"
output: "'hi'"
- output: "'hello' + ' world'"
-`
- src := StringSource(policyYAML, "test_policy.yaml")
- parser, err := NewParser()
- if err != nil {
- t.Fatalf("NewParser() failed: %v", err)
- }
- policy, iss := parser.Parse(src)
- if iss.Err() != nil {
- t.Fatalf("parser.Parse() failed: %v", iss.Err())
- }
-
- env, err := cel.NewEnv(cel.OptionalTypes(), ext.Bindings())
- if err != nil {
- t.Fatalf("cel.NewEnv() failed: %v", err)
- }
- compiledRule, iss := CompileRule(env, policy)
- if iss.Err() != nil {
- t.Fatalf("CompileRule() failed: %v", iss.Err())
- }
- composer, err := NewRuleComposer(env)
- if err != nil {
- t.Fatalf("NewRuleComposer() failed: %v", err)
- }
- compAST, iss := composer.Compose(compiledRule)
- if iss.Err() != nil {
- t.Fatalf("composer.Compose() failed: %v", iss.Err())
- }
-
- si := compAST.SourceInfo()
- if si.Location != "test_policy.yaml" {
- t.Errorf("SourceInfo.Location got %q, wanted test_policy.yaml", si.Location)
- }
- verifySourceInfoTransfer(t, compiledRule, compAST)
-}
-
-func TestCompose_Unnest(t *testing.T) {
- policyYAML := `name: unnest
+`,
+ checkInfo: true,
+ },
+ {
+ name: "unnest",
+ policy: `name: unnest
rule:
match:
- condition: "2 == 1"
output: "'hi'"
- output: "'hello'"
-`
- src := StringSource(policyYAML, "unnest.yaml")
- parser, err := NewParser()
- if err != nil {
- t.Fatalf("NewParser() failed: %v", err)
+`,
+ composerOpts: []ComposerOption{ExpressionUnnestHeight(1)},
+ checkInfo: true,
+ },
+ {
+ name: "empty_aggregate",
+ policy: `name: empty_nested_match_under_aggregate
+rule:
+ aggregate:
+ - condition: "true"
+ rule:
+ match: []
+`,
+ wantUnparsed: "[]",
+ wantEval: "[]",
+ },
+ {
+ name: "conditional_optional_nested",
+ policy: `name: conditional_optional_nested
+rule:
+ match:
+ - condition: "2 == 2"
+ rule:
+ match:
+ - condition: "1 == 1"
+ output: "'foo'"
+ - condition: "true"
+ rule:
+ match:
+ - condition: "3 == 3"
+ output: "'bar'"
+`,
+ wantUnparsed: `(2 == 2) ? ((1 == 1) ? optional.of("foo") : optional.none()) : ((3 == 3) ? optional.of("bar") : optional.none())`,
+ wantEval: `foo`,
+ },
}
- policy, iss := parser.Parse(src)
- if iss.Err() != nil {
- t.Fatalf("parser.Parse() failed: %v", iss.Err())
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ env, compiledRule, compAST := parseAndComposeRule(t, tc.policy, tc.name+".yaml", tc.composerOpts...)
+ if tc.checkInfo {
+ si := compAST.SourceInfo()
+ if si.Location != tc.name+".yaml" {
+ t.Errorf("SourceInfo.Location got %q, wanted %s.yaml", si.Location, tc.name)
+ }
+ verifySourceInfoTransfer(t, compiledRule, compAST)
+ if t.Failed() {
+ t.Logf("composed AST: %s", debug.ToDebugStringWithIDs(compAST.NativeRep().Expr()))
+ t.Logf("SourceInfo: %v", compAST.NativeRep().SourceInfo().OffsetRanges())
+ }
+ }
+ if tc.wantUnparsed != "" {
+ exprStr, err := cel.AstToString(compAST)
+ if err != nil {
+ t.Fatalf("cel.AstToString() failed: %v", err)
+ }
+ if normalize(exprStr) != normalize(tc.wantUnparsed) {
+ t.Errorf("cel.AstToString() got %q, wanted %q", exprStr, tc.wantUnparsed)
+ }
+ }
+ if tc.wantEval != "" {
+ prg, err := env.Program(compAST)
+ if err != nil {
+ t.Fatalf("env.Program() failed: %v", err)
+ }
+ res, _, err := prg.Eval(cel.NoVars())
+ if err != nil {
+ t.Fatalf("prg.Eval() failed: %v", err)
+ }
+ if fmt.Sprintf("%v", res.Value()) != tc.wantEval {
+ t.Errorf("eval result got %v, wanted %s", res.Value(), tc.wantEval)
+ }
+ }
+ })
}
+}
- env, err := cel.NewEnv(cel.OptionalTypes(), ext.Bindings())
+type testUnconditionalComposer struct{}
+
+func (t testUnconditionalComposer) Optimize(ctx *cel.OptimizerContext, a *ast.AST) *ast.AST {
+ trueCond := ctx.NewLiteral(types.True)
+ out1 := ctx.NewLiteral(types.String("first"))
+ out2 := ctx.NewLiteral(types.String("second"))
+
+ s := newNonOptionalCompositionStep(ctx, trueCond, out1)
+ step := newNonOptionalCompositionStep(ctx, trueCond, out2)
+
+ combined := s.combine(step)
+ return ctx.NewAST(combined.expr())
+}
+
+// Note: This test case cannot be reached through the policy format (because the compiler
+// statically rejects policies with unreachable outputs), but is expressed in code for defense in depth.
+func TestNonOptionalCompositionStep_UnconditionalCombine(t *testing.T) {
+ env, err := cel.NewEnv()
if err != nil {
t.Fatalf("cel.NewEnv() failed: %v", err)
}
- compiledRule, iss := CompileRule(env, policy)
+ opt, err := cel.NewStaticOptimizer(testUnconditionalComposer{})
+ if err != nil {
+ t.Fatalf("cel.NewStaticOptimizer() failed: %v", err)
+ }
+ dummyAST, _ := env.Compile("true")
+ resultAST, iss := opt.Optimize(env, dummyAST)
if iss.Err() != nil {
- t.Fatalf("CompileRule() failed: %v", iss.Err())
+ t.Fatalf("Optimize() failed: %v", iss.Err())
+ }
+ exprStr, err := cel.AstToString(resultAST)
+ if err != nil {
+ t.Fatalf("cel.AstToString() failed: %v", err)
}
+ if exprStr != `"first"` {
+ t.Errorf("got %q, wanted \"first\"", exprStr)
+ }
+}
- composer, err := NewRuleComposer(env, ExpressionUnnestHeight(1))
+func TestRuleComposerError(t *testing.T) {
+ env, err := cel.NewEnv()
if err != nil {
- t.Fatalf("NewRuleComposer() failed: %v", err)
+ t.Fatalf("NewEnv() failed: %v", err)
}
- compAST, iss := composer.Compose(compiledRule)
- if iss.Err() != nil {
- t.Fatalf("composer.Compose() failed: %v", iss.Err())
+ _, err = NewRuleComposer(env, ExpressionUnnestHeight(-1))
+ if err == nil || !strings.Contains(err.Error(), "invalid unnest") {
+ t.Errorf("NewRuleComposer() got %v, wanted 'invalid unnest'", err)
}
+}
- verifySourceInfoTransfer(t, compiledRule, compAST)
- if t.Failed() {
- t.Logf("composed AST: %s", debug.ToDebugStringWithIDs(compAST.NativeRep().Expr()))
- t.Logf("SourceInfo: %v", compAST.NativeRep().SourceInfo().OffsetRanges())
+func TestRuleComposerUnnest(t *testing.T) {
+ for _, tst := range composerUnnestTests {
+ tc := tst
+ t.Run(tc.name, func(t *testing.T) {
+ r := newRunner(tc.name, tc.expr, []ParserOption{})
+ env, rule, iss := r.compileRule(t)
+ if iss.Err() != nil {
+ t.Fatalf("CompileRule() failed: %v", iss.Err())
+ }
+ rc, err := NewRuleComposer(env, tc.composerOpts...)
+ if err != nil {
+ t.Fatalf("NewRuleComposer() failed: %v", err)
+ }
+ ast, iss := rc.Compose(rule)
+ if iss.Err() != nil {
+ t.Fatalf("Compose(rule) failed: %v", iss.Err())
+ }
+ policy := parsePolicy(t, tc.name, []ParserOption{})
+ verifySourceInfoCoverage(t, policy, ast)
+ unparsed, err := cel.AstToString(ast)
+ if err != nil {
+ t.Fatalf("cel.AstToString() failed: %v", err)
+ }
+ if normalize(unparsed) != normalize(tc.composed) {
+ t.Errorf("cel.AstToString() got %s, wanted %s", unparsed, tc.composed)
+ }
+ if !ast.OutputType().IsEquivalentType(tc.outputType) {
+ t.Errorf("ast.OutputType() got %v, wanted %v", ast.OutputType(), tc.outputType)
+ }
+ r.setup(t, env, ast)
+ r.run(t)
+ })
}
}
@@ -169,3 +276,33 @@ func (c *transferChecker) VisitExpr(srcExpr ast.Expr) {
func (c *transferChecker) VisitEntryExpr(ast.EntryExpr) {
}
+
+func parseAndComposeRule(t testing.TB, policyYAML, filename string, composerOpts ...ComposerOption) (*cel.Env, *CompiledRule, *cel.Ast) {
+ t.Helper()
+ src := StringSource(policyYAML, filename)
+ parser, err := NewParser()
+ if err != nil {
+ t.Fatalf("NewParser() failed: %v", err)
+ }
+ policy, iss := parser.Parse(src)
+ if iss.Err() != nil {
+ t.Fatalf("parser.Parse() failed: %v", iss.Err())
+ }
+ env, err := cel.NewEnv(cel.OptionalTypes(), ext.Bindings())
+ if err != nil {
+ t.Fatalf("cel.NewEnv() failed: %v", err)
+ }
+ compiledRule, iss := CompileRule(env, policy)
+ if iss.Err() != nil {
+ t.Fatalf("CompileRule() failed: %v", iss.Err())
+ }
+ composer, err := NewRuleComposer(env, composerOpts...)
+ if err != nil {
+ t.Fatalf("NewRuleComposer() failed: %v", err)
+ }
+ compAST, iss := composer.Compose(compiledRule)
+ if iss.Err() != nil {
+ t.Fatalf("composer.Compose() failed: %v", iss.Err())
+ }
+ return env, compiledRule, compAST
+}
diff --git a/policy/config_test.go b/policy/config_test.go
index 77fcce274..108e4972c 100644
--- a/policy/config_test.go
+++ b/policy/config_test.go
@@ -102,7 +102,7 @@ variables:
t.Fatalf("cel.NewEnv() failed: %v", err)
}
for _, tst := range tests {
- c := parseConfigYaml(t, tst)
+ c := parseConfigYAML(t, tst)
_, err := baseEnv.Extend(FromConfig(c))
if err != nil {
t.Errorf("AsEnvOptions() generated error: %v", err)
@@ -233,7 +233,7 @@ functions:
t.Fatalf("cel.NewEnv() failed: %v", err)
}
for _, tst := range tests {
- c := parseConfigYaml(t, tst.config)
+ c := parseConfigYAML(t, tst.config)
_, err := baseEnv.Extend(FromConfig(c))
if err == nil || err.Error() != tst.err {
t.Errorf("AsEnvOptions() got error: %v, wanted %s", err, tst.err)
@@ -241,7 +241,7 @@ functions:
}
}
-func parseConfigYaml(t *testing.T, doc string) *env.Config {
+func parseConfigYAML(t *testing.T, doc string) *env.Config {
config := &env.Config{}
if err := yaml.Unmarshal([]byte(doc), config); err != nil {
t.Fatalf("yaml.Unmarshal(%q) failed: %v", doc, err)
diff --git a/policy/helper_test.go b/policy/helper_test.go
index fbb62b55a..580b7e9fe 100644
--- a/policy/helper_test.go
+++ b/policy/helper_test.go
@@ -23,10 +23,10 @@ import (
"github.com/google/cel-go/common/env"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
+ "github.com/google/cel-go/common/types/traits"
"github.com/google/cel-go/test"
"go.yaml.in/yaml/v3"
-
)
var (
@@ -129,6 +129,28 @@ var (
? optional.of(((y == 1) ? optional.of("a") : optional.none()).orValue("b"))
: optional.none()`,
},
+ {
+ name: "agent_tool_execution_governance",
+ expr: `(request.is_emergency ? ["REQUIRE_VP_APPROVAL"] : ((tool.is_mutation && request.env == "prod") ? ["REQUIRE_TECH_LEAD_2FA"] : (tool.is_mutation ? ["REQUIRE_PEER_CONFIRMATION"] : []))) + ((classifier.has_credit_card(tool.call.args) ? ["REDACT_PCI"] : (classifier.has_email_or_phone(tool.call.args) ? ["REDACT_PII"] : [])) + ((tool.call.args.batch_size > 10000) ? ["THROTTLE_TIER_3"] : ((tool.call.args.batch_size > 1000) ? ["THROTTLE_TIER_2"] : ((tool.call.args.batch_size > 100) ? ["THROTTLE_TIER_1"] : []))))`,
+ envOpts: []cel.EnvOption{
+ cel.Function("classifier.has_credit_card",
+ cel.Overload("classifier_has_credit_card", []*cel.Type{cel.DynType}, cel.BoolType,
+ cel.UnaryBinding(func(args ref.Val) ref.Val {
+ if m, ok := args.(traits.Mapper); ok {
+ return types.Bool(m.Contains(types.String("cc")) == types.True)
+ }
+ return types.False
+ }))),
+ cel.Function("classifier.has_email_or_phone",
+ cel.Overload("classifier_has_email_or_phone", []*cel.Type{cel.DynType}, cel.BoolType,
+ cel.UnaryBinding(func(args ref.Val) ref.Val {
+ if m, ok := args.(traits.Mapper); ok {
+ return types.Bool(m.Contains(types.String("email")) == types.True || m.Contains(types.String("phone")) == types.True)
+ }
+ return types.False
+ }))),
+ },
+ },
}
composerUnnestTests = []struct {
@@ -270,6 +292,9 @@ ERROR: testdata/errors/policy.yaml:45:16: incompatible output types: block has o
| ........^
ERROR: testdata/errors_unreachable/policy.yaml:36:13: match creates unreachable outputs
| - output: |
+ | ............^
+ERROR: testdata/errors_unreachable/policy.yaml:38:13: Condition is always false
+ | - condition: "false"
| ............^`,
},
{
@@ -278,6 +303,30 @@ ERROR: testdata/errors_unreachable/policy.yaml:36:13: match creates unreachable
| match:
| ........^`,
},
+ {
+ name: "aggregate_errors",
+ err: `ERROR: testdata/aggregate_errors/policy.yaml:21:13: match creates unreachable outputs
+ | - condition: "true"
+ | ............^
+ERROR: testdata/aggregate_errors/policy.yaml:24:22: incompatible output types: block has output type int, but previous outputs have type optional_type(string)
+ | output: "403"
+ | .....................^`,
+ },
+ {
+ name: "aggregate_list_errors",
+ err: `ERROR: testdata/aggregate_list_errors/policy.yaml:21:13: match creates unreachable outputs
+ | - condition: "true"
+ | ............^
+ERROR: testdata/aggregate_list_errors/policy.yaml:24:22: incompatible output types: block has output type int, but previous outputs have type list(string)
+ | output: "403"
+ | .....................^`,
+ },
+ {
+ name: "aggregate_nested_mixed_semantics",
+ err: `ERROR: testdata/aggregate_nested_mixed_semantics/policy.yaml:23:15: nested aggregate rules are not allowed
+ | aggregate:
+ | ..............^`,
+ },
}
)
diff --git a/policy/parser.go b/policy/parser.go
index a42c7a3b9..077c70b2c 100644
--- a/policy/parser.go
+++ b/policy/parser.go
@@ -25,11 +25,12 @@ import (
"github.com/google/cel-go/common/ast"
)
-type semanticType int
+type SemanticType int
const (
- unspecified semanticType = iota
+ unspecified SemanticType = iota
firstMatch
+ aggregate
)
// NewPolicy creates a policy object which references a policy source and source information.
@@ -38,7 +39,7 @@ func NewPolicy(src *Source, info *ast.SourceInfo) *Policy {
metadata: map[string]any{},
source: src,
info: info,
- semantic: firstMatch,
+ semantic: unspecified,
imports: []*Import{},
}
}
@@ -49,13 +50,29 @@ type Policy struct {
description ValueString
imports []*Import
rule *Rule
- semantic semanticType
+ semantic SemanticType
info *ast.SourceInfo
source *Source
metadata map[string]any
}
+// Semantic returns the evaluation semantic for the policy.
+func (p *Policy) Semantic() SemanticType {
+ if p.semantic == unspecified {
+ return firstMatch
+ }
+ return p.semantic
+}
+
+// SetSemantic configures the evaluation semantic for the policy.
+func (p *Policy) SetSemantic(s SemanticType) {
+ if p.semantic != unspecified && p.semantic != s {
+ return
+ }
+ p.semantic = s
+}
+
// Source returns the policy file contents as a CEL source object.
func (p *Policy) Source() *Source {
return p.source
@@ -179,6 +196,7 @@ func NewRule(exprID int64) *Rule {
exprID: exprID,
variables: []*Variable{},
matches: []*Match{},
+ semantic: unspecified,
}
}
@@ -189,6 +207,28 @@ type Rule struct {
description *ValueString
variables []*Variable
matches []*Match
+ semantic SemanticType
+}
+
+// Semantic returns the evaluation semantic for the rule.
+func (r *Rule) Semantic() SemanticType {
+ if r.semantic == unspecified {
+ return firstMatch
+ }
+ return r.semantic
+}
+
+// SetSemantic configures the evaluation semantic for the rule.
+func (r *Rule) SetSemantic(s SemanticType) {
+ if r.semantic != unspecified && r.semantic != s {
+ return
+ }
+ r.semantic = s
+}
+
+// SourceID returns the source identifier associated with the rule.
+func (r *Rule) SourceID() int64 {
+ return r.exprID
}
// ID returns the id value of the rule if it is set.
@@ -249,6 +289,7 @@ func (r *Rule) getExplanationOutputRule() *Rule {
er := Rule{
id: r.id,
description: r.description,
+ semantic: r.semantic,
}
er.AddVariables(r.Variables())
for _, match := range r.matches {
@@ -769,8 +810,18 @@ func (p *parserImpl) ParseRule(ctx ParserContext, policy *Policy, node *yaml.Nod
r.SetDescription(ctx.NewString(val))
case "variables":
p.parseVariables(ctx, policy, r, val)
- case "match":
- p.parseMatches(ctx, policy, r, val)
+ case "match", "aggregate":
+ sem := firstMatch
+ if fieldName == "aggregate" {
+ sem = aggregate
+ }
+ if r.semantic != unspecified && r.semantic != sem {
+ p.ReportErrorAtID(tagID, "Only one of 'match' or 'aggregate' may be set in a rule")
+ } else {
+ r.SetSemantic(sem)
+ policy.SetSemantic(sem)
+ p.parseMatches(ctx, policy, r, val)
+ }
default:
p.visitor.RuleTag(ctx, tagID, fieldName, val, policy, r)
}
@@ -841,16 +892,27 @@ func (p *parserImpl) parseMatches(ctx ParserContext, policy *Policy, r *Rule, no
return
}
for _, val := range node.Content {
- r.AddMatch(p.ParseMatch(ctx, policy, val))
+ r.AddMatch(p.parseMatchInternal(ctx, policy, r, val))
}
}
// ParseMatch will parse the current yaml node as though it is the entry point to a match.
func (p *parserImpl) ParseMatch(ctx ParserContext, policy *Policy, node *yaml.Node) *Match {
+ return p.parseMatchInternal(ctx, policy, nil, node)
+}
+
+func (p *parserImpl) parseMatchInternal(ctx ParserContext, policy *Policy, r *Rule, node *yaml.Node) *Match {
m, id := ctx.NewMatch(node)
if p.assertYAMLType(id, node, yamlMap) == nil || !p.checkMapValid(ctx, id, node) {
return m
}
+ ruleSem := firstMatch
+ if r != nil {
+ ruleSem = r.Semantic()
+ } else {
+ ruleSem = policy.Semantic()
+ }
+ isAggregate := ruleSem == aggregate
m.SetCondition(ValueString{ID: ctx.NextID(), Value: "true"})
p.RangeMap(node, func(key, val *yaml.Node) bool {
keyID := ctx.CollectMetadata(key)
@@ -858,7 +920,12 @@ func (p *parserImpl) ParseMatch(ctx ParserContext, policy *Policy, node *yaml.No
switch fieldName {
case "condition":
m.SetCondition(ctx.NewString(val))
- case "output":
+ case "output", "emit":
+ if fieldName == "output" && isAggregate {
+ p.ReportErrorAtID(keyID, "Rule aggregate requires 'emit' tag instead of 'output'")
+ } else if fieldName == "emit" && !isAggregate {
+ p.ReportErrorAtID(keyID, "Rule match requires 'output' tag instead of 'emit'")
+ }
if m.HasRule() {
p.ReportErrorAtID(keyID, "only the rule or the output may be set")
}
@@ -868,7 +935,7 @@ func (p *parserImpl) ParseMatch(ctx ParserContext, policy *Policy, node *yaml.No
p.ReportErrorAtID(keyID, "explanation can only be set on output match cases, not nested rules")
}
m.SetExplanation(ctx.NewString(val))
- case "rule":
+ case "rule", "match", "aggregate":
if m.HasOutput() {
p.ReportErrorAtID(keyID, "only the rule or the output may be set")
}
diff --git a/policy/parser_test.go b/policy/parser_test.go
index f407a8600..a2468494e 100644
--- a/policy/parser_test.go
+++ b/policy/parser_test.go
@@ -147,6 +147,39 @@ rule:
},
{
txt: `
+rule:
+ aggregate:
+ - condition: "true"
+ output: "'foo'"`,
+ err: `ERROR: :5:7: Rule aggregate requires 'emit' tag instead of 'output'
+ | output: "'foo'"
+ | ......^`,
+ },
+ {
+ txt: `
+rule:
+ match:
+ - condition: "true"
+ emit: "'foo'"`,
+ err: `ERROR: :5:7: Rule match requires 'output' tag instead of 'emit'
+ | emit: "'foo'"
+ | ......^`,
+ },
+ {
+ txt: `
+rule:
+ match:
+ - condition: "true"
+ output: "'foo'"
+ aggregate:
+ - condition: "true"
+ emit: "'bar'"`,
+ err: `ERROR: :6:3: Only one of 'match' or 'aggregate' may be set in a rule
+ | aggregate:
+ | ..^`,
+ },
+ {
+ txt: `
rule:
match:
- condition: "true"
@@ -215,6 +248,50 @@ rule:
- name`,
err: `ERROR: :4:7: got yaml node type tag:yaml.org,2002:str, wanted type(s) [tag:yaml.org,2002:map]
| - name
+ | ......^`,
+ },
+ {
+ txt: `
+name: test
+rule:
+ match:
+ - output: 'true'
+ aggregate:
+ - emit: 'true'`,
+ err: `ERROR: :6:3: Only one of 'match' or 'aggregate' may be set in a rule
+ | aggregate:
+ | ..^`,
+ },
+ {
+ txt: `
+name: test
+rule:
+ aggregate:
+ - emit: 'true'
+ match:
+ - output: 'true'`,
+ err: `ERROR: :6:3: Only one of 'match' or 'aggregate' may be set in a rule
+ | match:
+ | ..^`,
+ },
+ {
+ txt: `
+name: test
+rule:
+ aggregate:
+ - output: 'true'`,
+ err: `ERROR: :5:7: Rule aggregate requires 'emit' tag instead of 'output'
+ | - output: 'true'
+ | ......^`,
+ },
+ {
+ txt: `
+name: test
+rule:
+ match:
+ - emit: 'true'`,
+ err: `ERROR: :5:7: Rule match requires 'output' tag instead of 'emit'
+ | - emit: 'true'
| ......^`,
},
}
@@ -389,3 +466,36 @@ func (t *testTagHandler) PolicyTag(ctx ParserContext, id int64, tagName string,
p.SetMetadata(tagName, node.Value)
}
}
+
+func TestPolicyAndRuleSemanticMethods(t *testing.T) {
+ p := NewPolicy(nil, nil)
+ if p.Semantic() != firstMatch {
+ t.Errorf("got %v, wanted firstMatch", p.Semantic())
+ }
+ p.SetSemantic(aggregate)
+ if p.Semantic() != aggregate {
+ t.Errorf("got %v, wanted aggregate", p.Semantic())
+ }
+ // Attempt to set conflicting semantic
+ p.SetSemantic(firstMatch)
+ if p.Semantic() != aggregate {
+ t.Errorf("got %v, wanted aggregate after conflicting SetSemantic", p.Semantic())
+ }
+
+ r := NewRule(123)
+ if r.SourceID() != 123 {
+ t.Errorf("got %v, wanted 123", r.SourceID())
+ }
+ if r.Semantic() != firstMatch {
+ t.Errorf("got %v, wanted firstMatch", r.Semantic())
+ }
+ r.SetSemantic(aggregate)
+ if r.Semantic() != aggregate {
+ t.Errorf("got %v, wanted aggregate", r.Semantic())
+ }
+ // Attempt to set conflicting semantic
+ r.SetSemantic(firstMatch)
+ if r.Semantic() != aggregate {
+ t.Errorf("got %v, wanted aggregate after conflicting SetSemantic", r.Semantic())
+ }
+}
diff --git a/policy/testdata/agent_tool_execution_governance/config.yaml b/policy/testdata/agent_tool_execution_governance/config.yaml
new file mode 100644
index 000000000..83a111eea
--- /dev/null
+++ b/policy/testdata/agent_tool_execution_governance/config.yaml
@@ -0,0 +1,42 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: agent_tool_execution_governance
+variables:
+ - name: "request.is_emergency"
+ type_name: "bool"
+ - name: "request.env"
+ type_name: "string"
+ - name: "tool.is_mutation"
+ type_name: "bool"
+ - name: "tool.call.args"
+ type_name: "map"
+ params:
+ - type_name: "string"
+ - type_name: "dyn"
+functions:
+ - name: "classifier.has_credit_card"
+ overloads:
+ - id: "classifier_has_credit_card"
+ args:
+ - type_name: "dyn"
+ return:
+ type_name: "bool"
+ - name: "classifier.has_email_or_phone"
+ overloads:
+ - id: "classifier_has_email_or_phone"
+ args:
+ - type_name: "dyn"
+ return:
+ type_name: "bool"
diff --git a/policy/testdata/agent_tool_execution_governance/policy.yaml b/policy/testdata/agent_tool_execution_governance/policy.yaml
new file mode 100644
index 000000000..0a88e8ac5
--- /dev/null
+++ b/policy/testdata/agent_tool_execution_governance/policy.yaml
@@ -0,0 +1,44 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: agent_tool_execution_governance
+rule:
+ aggregate:
+ # Dimension 1: Approval Requirements (First-Match Escalation)
+ - rule:
+ match:
+ - condition: "request.is_emergency"
+ output: "'REQUIRE_VP_APPROVAL'"
+ - condition: "tool.is_mutation && request.env == 'prod'"
+ output: "'REQUIRE_TECH_LEAD_2FA'"
+ - condition: "tool.is_mutation"
+ output: "'REQUIRE_PEER_CONFIRMATION'"
+
+ # Dimension 2: Data Redaction (First-Match Specificity)
+ - rule:
+ match:
+ - condition: "classifier.has_credit_card(tool.call.args)"
+ output: "'REDACT_PCI'"
+ - condition: "classifier.has_email_or_phone(tool.call.args)"
+ output: "'REDACT_PII'"
+
+ # Dimension 3: Rate Limiting (First-Match Threshold Ladder)
+ - rule:
+ match:
+ - condition: "tool.call.args.batch_size > 10000"
+ output: "'THROTTLE_TIER_3'"
+ - condition: "tool.call.args.batch_size > 1000"
+ output: "'THROTTLE_TIER_2'"
+ - condition: "tool.call.args.batch_size > 100"
+ output: "'THROTTLE_TIER_1'"
diff --git a/policy/testdata/agent_tool_execution_governance/tests.yaml b/policy/testdata/agent_tool_execution_governance/tests.yaml
new file mode 100644
index 000000000..ef4aa11a9
--- /dev/null
+++ b/policy/testdata/agent_tool_execution_governance/tests.yaml
@@ -0,0 +1,72 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+description: "Tests governance policy evaluation with multi-dimensional aggregate rules"
+section:
+ - name: "emergency_approval"
+ tests:
+ - name: "emergency_trumps_all_approval_rules"
+ input:
+ request.is_emergency:
+ value: true
+ request.env:
+ value: "prod"
+ tool.is_mutation:
+ value: true
+ tool.call.args:
+ expr: "{'batch_size': 50}"
+ output:
+ expr: "['REQUIRE_VP_APPROVAL']"
+ - name: "prod_mutation_with_pci_and_throttling"
+ tests:
+ - name: "prod_mutation_pci_tier2"
+ input:
+ request.is_emergency:
+ value: false
+ request.env:
+ value: "prod"
+ tool.is_mutation:
+ value: true
+ tool.call.args:
+ expr: "{'batch_size': dyn(1500), 'cc': dyn('411111111111')}"
+ output:
+ expr: "['REQUIRE_TECH_LEAD_2FA', 'REDACT_PCI', 'THROTTLE_TIER_2']"
+ - name: "dev_mutation_with_pii_and_tier1"
+ tests:
+ - name: "dev_mutation_pii_tier1"
+ input:
+ request.is_emergency:
+ value: false
+ request.env:
+ value: "dev"
+ tool.is_mutation:
+ value: true
+ tool.call.args:
+ expr: "{'batch_size': dyn(500), 'email': dyn('user@example.com')}"
+ output:
+ expr: "['REQUIRE_PEER_CONFIRMATION', 'REDACT_PII', 'THROTTLE_TIER_1']"
+ - name: "read_only_tool"
+ tests:
+ - name: "no_rules_matched"
+ input:
+ request.is_emergency:
+ value: false
+ request.env:
+ value: "prod"
+ tool.is_mutation:
+ value: false
+ tool.call.args:
+ expr: "{'batch_size': 10}"
+ output:
+ expr: "[]"
diff --git a/policy/testdata/aggregate_errors/config.yaml b/policy/testdata/aggregate_errors/config.yaml
new file mode 100644
index 000000000..b0c22629d
--- /dev/null
+++ b/policy/testdata/aggregate_errors/config.yaml
@@ -0,0 +1,15 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: aggregate_errors
diff --git a/policy/testdata/aggregate_errors/policy.yaml b/policy/testdata/aggregate_errors/policy.yaml
new file mode 100644
index 000000000..b0f73e174
--- /dev/null
+++ b/policy/testdata/aggregate_errors/policy.yaml
@@ -0,0 +1,24 @@
+# Copyright 2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: aggregate_errors
+rule:
+ aggregate:
+ - condition: "true"
+ rule:
+ match:
+ - condition: "true"
+ output: "optional.of('USER_PII')"
+ - condition: "true"
+ output: "403"
diff --git a/policy/testdata/aggregate_list_errors/config.yaml b/policy/testdata/aggregate_list_errors/config.yaml
new file mode 100644
index 000000000..20edb6308
--- /dev/null
+++ b/policy/testdata/aggregate_list_errors/config.yaml
@@ -0,0 +1,15 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: aggregate_list_errors
diff --git a/policy/testdata/aggregate_list_errors/policy.yaml b/policy/testdata/aggregate_list_errors/policy.yaml
new file mode 100644
index 000000000..3836e59f1
--- /dev/null
+++ b/policy/testdata/aggregate_list_errors/policy.yaml
@@ -0,0 +1,24 @@
+# Copyright 2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: aggregate_list_errors
+rule:
+ aggregate:
+ - condition: "true"
+ rule:
+ match:
+ - condition: "true"
+ output: "['tag1', 'tag2']"
+ - condition: "true"
+ output: "403"
diff --git a/policy/testdata/aggregate_nested_mixed_semantics/config.yaml b/policy/testdata/aggregate_nested_mixed_semantics/config.yaml
new file mode 100644
index 000000000..2c61341c9
--- /dev/null
+++ b/policy/testdata/aggregate_nested_mixed_semantics/config.yaml
@@ -0,0 +1,15 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: aggregate_nested_mixed_semantics
diff --git a/policy/testdata/aggregate_nested_mixed_semantics/policy.yaml b/policy/testdata/aggregate_nested_mixed_semantics/policy.yaml
new file mode 100644
index 000000000..02b2ef28a
--- /dev/null
+++ b/policy/testdata/aggregate_nested_mixed_semantics/policy.yaml
@@ -0,0 +1,25 @@
+# Copyright 2024 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: aggregate_nested_mixed_semantics
+rule:
+ aggregate:
+ - condition: "true"
+ rule:
+ match:
+ - condition: "true"
+ rule:
+ aggregate:
+ - condition: "true"
+ emit: "'foo'"