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'"