From 1a5ff76e2363aa067f6228a58ad7daf6a63564b7 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:22:46 +0200 Subject: [PATCH 01/12] feat(evaluate): add --output-rule to evaluate input Adds the value of a policy rule to the JSON output, next to allow and violations. Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateHelpers.go | 7 +- cmd/kosli/evaluateInput.go | 6 +- cmd/kosli/evaluateInput_test.go | 16 +++++ cmd/kosli/evaluateTrail.go | 2 +- cmd/kosli/evaluateTrails.go | 2 +- cmd/kosli/root.go | 1 + .../testdata/policies/allow-with-report.rego | 5 ++ .../testdata/policies/deny-with-report.rego | 5 ++ internal/evaluate/rego.go | 67 +++++++++++-------- internal/evaluate/rego_test.go | 16 +++++ 10 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 cmd/kosli/testdata/policies/allow-with-report.rego create mode 100644 cmd/kosli/testdata/policies/deny-with-report.rego diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index f1b332c2f..c505ea70c 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -254,13 +254,13 @@ func parseParams(raw string) (map[string]any, error) { return params, nil } -func evaluateAndPrintResult(out io.Writer, policyRef string, input map[string]any, outputFormat string, showInput bool, params map[string]any, assertOnDeny bool) error { +func evaluateAndPrintResult(out io.Writer, policyRef string, input map[string]any, outputFormat string, showInput bool, params map[string]any, assertOnDeny bool, outputRules []string) error { policySource, err := loadPolicy(policyRef) if err != nil { return err } - result, err := evaluate.Evaluate(string(policySource), input, params) + result, err := evaluate.Evaluate(string(policySource), input, params, outputRules...) if err != nil { return err } @@ -538,6 +538,9 @@ func printEvaluateResult(out io.Writer, result *evaluate.Result, input map[strin "allow": result.Allow, "violations": result.Violations, } + for rule, value := range result.Outputs { + auditResult[rule] = value + } // Absent everywhere else, so a caller reading a verdict alone parses the // same page as before. if decisionID != "" { diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index ca1c96deb..5cf92a30a 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -12,7 +12,8 @@ import ( type evaluateInputOptions struct { commonEvaluateOptions - inputFile string + inputFile string + outputRules []string } const evaluateInputShortDesc = `Evaluate a local JSON input against a Rego policy.` @@ -95,6 +96,7 @@ func newEvaluateInputCmd(out io.Writer) *cobra.Command { o.addFlags(cmd, "Path or http(s):// URL of a Rego policy to evaluate against the input.") cmd.Flags().StringVarP(&o.inputFile, "input-file", "i", "", "[optional] Path to a JSON input file. Reads from stdin if omitted.") + cmd.Flags().StringSliceVar(&o.outputRules, "output-rule", nil, policyOutputRuleFlag) cmd.Flags().Lookup("flow").Hidden = true cmd.Flags().Lookup("attestations").Hidden = true @@ -128,7 +130,7 @@ func (o *evaluateInputOptions) run(out io.Writer, in io.Reader) error { return err } - return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), o.outputRules) } func loadInputFromFile(filePath string) (result map[string]any, err error) { diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index 077fe636a..5c2a1fcf9 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -144,6 +144,22 @@ func (suite *EvaluateInputCommandTestSuite) TestEvaluateInputCmd() { {"allow", false}, }, }, + { + name: "--output-rule adds the rule to the JSON output", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-with-report.rego --output-rule report --output json", + goldenJson: []jsonCheck{ + {"allow", true}, + {"report.compliant", true}, + }, + }, + { + name: "--output-rule adds the rule to the JSON output when the policy denies", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/deny-with-report.rego --output-rule report --output json --no-assert", + goldenJson: []jsonCheck{ + {"allow", false}, + {"report.compliant", false}, + }, + }, } runTestCmd(suite.T(), tests) } diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index d84bcb19a..229cb2d51 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -128,5 +128,5 @@ func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { "trail": trailData, } - return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), nil) } diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index 88e910ce9..38fe7c9fe 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -128,5 +128,5 @@ func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { "trails": trails, } - return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny()) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), nil) } diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 2f67671c9..8ee83602d 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -148,6 +148,7 @@ Paths the list already matches stay excluded whatever is later added there, so k outputFlag = "[defaulted] The format of the output. Valid formats are: [table, json]." serverSideFlag = "[hidden] Evaluate the policy on the Kosli server rather than on this machine. Unsupported and subject to change." policyParamsFlag = "[optional] Policy parameters as inline JSON or @file.json. Available in policies as data.params." + policyOutputRuleFlag = "[optional] Name of a policy rule to add to the JSON output, next to allow and violations." policyAssertFlag = "[optional] Exit with a non-zero status when the policy denies. Without it the verdict is printed and the command exits 0." policyContextFlag = "What to evaluate, as trail=/. Repeat it to evaluate several trails at one instant." policyControlFlag = "[optional] Record the outcome as a decision against this control. Without it nothing is recorded." diff --git a/cmd/kosli/testdata/policies/allow-with-report.rego b/cmd/kosli/testdata/policies/allow-with-report.rego new file mode 100644 index 000000000..0914d0dc0 --- /dev/null +++ b/cmd/kosli/testdata/policies/allow-with-report.rego @@ -0,0 +1,5 @@ +package policy + +allow = true + +report := {"compliant": true} diff --git a/cmd/kosli/testdata/policies/deny-with-report.rego b/cmd/kosli/testdata/policies/deny-with-report.rego new file mode 100644 index 000000000..00bf63301 --- /dev/null +++ b/cmd/kosli/testdata/policies/deny-with-report.rego @@ -0,0 +1,5 @@ +package policy + +allow = false + +report := {"compliant": false} diff --git a/internal/evaluate/rego.go b/internal/evaluate/rego.go index db50a9230..401b2d489 100644 --- a/internal/evaluate/rego.go +++ b/internal/evaluate/rego.go @@ -13,31 +13,21 @@ import ( type Result struct { Allow bool Violations []string + Outputs map[string]any } // Evaluate evaluates a Rego policy against the given input. // The policy must use `package policy` and declare an `allow` rule. // An optional params map can be provided to populate data.params in the policy. -func Evaluate(policySource string, input any, params map[string]any) (*Result, error) { +// The values of outputRules, rules of the policy package, are returned in Result.Outputs. +func Evaluate(policySource string, input any, params map[string]any, outputRules ...string) (*Result, error) { if err := validatePolicy(policySource); err != nil { return nil, err } ctx := context.Background() - opts := []func(*rego.Rego){ - rego.Query("data.policy.allow"), - rego.Module("policy.rego", policySource), - rego.Input(input), - } - if params != nil { - store := inmem.NewFromObject(map[string]any{"params": params}) - opts = append(opts, rego.Store(store)) - } - - r := rego.New(opts...) - - rs, err := r.Eval(ctx) + rs, err := evalQuery(ctx, "data.policy.allow", policySource, input, params) if err != nil { return nil, fmt.Errorf("policy evaluation failed: %w", err) } @@ -61,9 +51,31 @@ func Evaluate(policySource string, input any, params map[string]any) (*Result, e result.Violations = violations } + for _, rule := range outputRules { + value, err := evaluateRule(ctx, policySource, input, params, rule) + if err != nil { + return nil, err + } + if result.Outputs == nil { + result.Outputs = map[string]any{} + } + result.Outputs[rule] = value + } + return result, nil } +func evaluateRule(ctx context.Context, policySource string, input any, params map[string]any, rule string) (any, error) { + rs, err := evalQuery(ctx, "data.policy."+rule, policySource, input, params) + if err != nil { + return nil, fmt.Errorf("%s evaluation failed: %w", rule, err) + } + if len(rs) == 0 || len(rs[0].Expressions) == 0 { + return nil, nil + } + return rs[0].Expressions[0].Value, nil +} + func validatePolicy(policySource string) error { module, err := ast.ParseModuleWithOpts("policy.rego", policySource, ast.ParserOptions{}) if err != nil { @@ -90,19 +102,7 @@ func validatePolicy(policySource string) error { } func collectViolations(ctx context.Context, policySource string, input any, params map[string]any) ([]string, error) { - opts := []func(*rego.Rego){ - rego.Query("data.policy.violations"), - rego.Module("policy.rego", policySource), - rego.Input(input), - } - if params != nil { - store := inmem.NewFromObject(map[string]any{"params": params}) - opts = append(opts, rego.Store(store)) - } - - r := rego.New(opts...) - - rs, err := r.Eval(ctx) + rs, err := evalQuery(ctx, "data.policy.violations", policySource, input, params) if err != nil { return nil, fmt.Errorf("violations evaluation failed: %w", err) } @@ -120,3 +120,16 @@ func collectViolations(ctx context.Context, policySource string, input any, para return violations, nil } + +func evalQuery(ctx context.Context, query string, policySource string, input any, params map[string]any) (rego.ResultSet, error) { + opts := []func(*rego.Rego){ + rego.Query(query), + rego.Module("policy.rego", policySource), + rego.Input(input), + } + if params != nil { + store := inmem.NewFromObject(map[string]any{"params": params}) + opts = append(opts, rego.Store(store)) + } + return rego.New(opts...).Eval(ctx) +} diff --git a/internal/evaluate/rego_test.go b/internal/evaluate/rego_test.go index d5d047812..3ae0e2053 100644 --- a/internal/evaluate/rego_test.go +++ b/internal/evaluate/rego_test.go @@ -181,3 +181,19 @@ allow = true require.NoError(t, err) require.True(t, result.Allow, "params not referenced by policy should have no effect") } + +func TestEvaluate_OutputRules(t *testing.T) { + for _, allow := range []string{"true", "false"} { + t.Run("allow = "+allow, func(t *testing.T) { + policy := `package policy + +allow = ` + allow + ` + +report := {"compliant": ` + allow + `} +` + result, err := Evaluate(policy, map[string]any{}, nil, "report") + require.NoError(t, err) + require.Equal(t, map[string]any{"report": map[string]any{"compliant": allow == "true"}}, result.Outputs) + }) + } +} From 85075c45764e040044ffe5f366bf9629b371c853 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:23:14 +0200 Subject: [PATCH 02/12] test(evaluate): pin repeated and comma-separated --output-rule Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateInput_test.go | 16 ++++++++++++++++ .../testdata/policies/allow-with-report.rego | 2 ++ 2 files changed, 18 insertions(+) diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index 5c2a1fcf9..b28c02c0b 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -160,6 +160,22 @@ func (suite *EvaluateInputCommandTestSuite) TestEvaluateInputCmd() { {"report.compliant", false}, }, }, + { + name: "--output-rule can be repeated", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-with-report.rego --output-rule report --output-rule summary --output json", + goldenJson: []jsonCheck{ + {"report.compliant", true}, + {"summary", "all good"}, + }, + }, + { + name: "--output-rule takes a comma-separated list", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-with-report.rego --output-rule report,summary --output json", + goldenJson: []jsonCheck{ + {"report.compliant", true}, + {"summary", "all good"}, + }, + }, } runTestCmd(suite.T(), tests) } diff --git a/cmd/kosli/testdata/policies/allow-with-report.rego b/cmd/kosli/testdata/policies/allow-with-report.rego index 0914d0dc0..3804ec458 100644 --- a/cmd/kosli/testdata/policies/allow-with-report.rego +++ b/cmd/kosli/testdata/policies/allow-with-report.rego @@ -3,3 +3,5 @@ package policy allow = true report := {"compliant": true} + +summary := "all good" From f7ed5e72fdae1b41d103e1f6e3c2c67f93c75b63 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:23:52 +0200 Subject: [PATCH 03/12] feat(evaluate): fail on an --output-rule the policy does not declare A declared rule with no value for the input prints null. Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateInput_test.go | 13 +++++++++++ .../testdata/policies/undefined-report.rego | 7 ++++++ internal/evaluate/rego.go | 18 ++++++++------- internal/evaluate/rego_test.go | 23 +++++++++++++++++++ 4 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 cmd/kosli/testdata/policies/undefined-report.rego diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index b28c02c0b..b49eec8c6 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -176,6 +176,19 @@ func (suite *EvaluateInputCommandTestSuite) TestEvaluateInputCmd() { {"summary", "all good"}, }, }, + { + wantError: true, + name: "--output-rule naming a rule the policy does not declare fails", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-all.rego --output-rule report --output json", + goldenRegex: `policy does not declare a 'report' rule`, + }, + { + name: "--output-rule prints null for a rule with no value", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/undefined-report.rego --output-rule report --output json", + goldenJson: []jsonCheck{ + {"report", nil}, + }, + }, } runTestCmd(suite.T(), tests) } diff --git a/cmd/kosli/testdata/policies/undefined-report.rego b/cmd/kosli/testdata/policies/undefined-report.rego new file mode 100644 index 000000000..8803a37e0 --- /dev/null +++ b/cmd/kosli/testdata/policies/undefined-report.rego @@ -0,0 +1,7 @@ +package policy + +allow = true + +report := {"compliant": true} if { + input.never_there +} diff --git a/internal/evaluate/rego.go b/internal/evaluate/rego.go index 401b2d489..ed83ac83d 100644 --- a/internal/evaluate/rego.go +++ b/internal/evaluate/rego.go @@ -21,7 +21,7 @@ type Result struct { // An optional params map can be provided to populate data.params in the policy. // The values of outputRules, rules of the policy package, are returned in Result.Outputs. func Evaluate(policySource string, input any, params map[string]any, outputRules ...string) (*Result, error) { - if err := validatePolicy(policySource); err != nil { + if err := validatePolicy(policySource, outputRules); err != nil { return nil, err } @@ -76,7 +76,7 @@ func evaluateRule(ctx context.Context, policySource string, input any, params ma return rs[0].Expressions[0].Value, nil } -func validatePolicy(policySource string) error { +func validatePolicy(policySource string, outputRules []string) error { module, err := ast.ParseModuleWithOpts("policy.rego", policySource, ast.ParserOptions{}) if err != nil { return fmt.Errorf("failed to parse policy: %w", err) @@ -87,16 +87,18 @@ func validatePolicy(policySource string) error { module.Package.Path[1:].String()) } - hasAllow := false + declared := map[string]bool{} for _, rule := range module.Rules { - if rule.Head.Name.String() == "allow" { - hasAllow = true - break - } + declared[rule.Head.Name.String()] = true } - if !hasAllow { + if !declared["allow"] { return fmt.Errorf("policy must declare an 'allow' rule") } + for _, rule := range outputRules { + if !declared[rule] { + return fmt.Errorf("policy does not declare a '%s' rule", rule) + } + } return nil } diff --git a/internal/evaluate/rego_test.go b/internal/evaluate/rego_test.go index 3ae0e2053..af1603e03 100644 --- a/internal/evaluate/rego_test.go +++ b/internal/evaluate/rego_test.go @@ -197,3 +197,26 @@ report := {"compliant": ` + allow + `} }) } } + +func TestEvaluate_OutputRuleNotDeclared(t *testing.T) { + policy := `package policy + +allow = true +` + _, err := Evaluate(policy, map[string]any{}, nil, "report") + require.EqualError(t, err, "policy does not declare a 'report' rule") +} + +func TestEvaluate_OutputRuleUndefined(t *testing.T) { + policy := `package policy + +allow = true + +report := "never" if { + input.never_there +} +` + result, err := Evaluate(policy, map[string]any{}, nil, "report") + require.NoError(t, err) + require.Equal(t, map[string]any{"report": nil}, result.Outputs) +} From 8d2122bf1822e630a3fa38a92d30a0c6b5543b5d Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:24:30 +0200 Subject: [PATCH 04/12] feat(evaluate): refuse an --output-rule that clashes with an output key Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateHelpers.go | 12 ++++++++++++ cmd/kosli/evaluateInput.go | 4 ++++ cmd/kosli/evaluateInput_test.go | 13 +++++++++++++ 3 files changed, 29 insertions(+) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index c505ea70c..4c7303c6f 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -12,6 +12,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "time" @@ -533,6 +534,17 @@ func policyBundleKey(ref string) string { // printEvaluateResult renders a verdict, whatever produced it, so that every // evaluation path prints the same bytes for the same verdict. +var evaluateResultKeys = []string{"allow", "violations", "input", "params", "decision_attestation_id"} + +func validateOutputRules(rules []string) error { + for _, rule := range rules { + if slices.Contains(evaluateResultKeys, rule) { + return fmt.Errorf("--output-rule cannot be '%s', it is already part of the output", rule) + } + } + return nil +} + func printEvaluateResult(out io.Writer, result *evaluate.Result, input map[string]any, outputFormat string, showInput bool, params map[string]any, assertOnDeny bool, decisionID string) error { auditResult := map[string]any{ "allow": result.Allow, diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index 5cf92a30a..53785b16a 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -110,6 +110,10 @@ func newEvaluateInputCmd(out io.Writer) *cobra.Command { } func (o *evaluateInputOptions) run(out io.Writer, in io.Reader) error { + if err := validateOutputRules(o.outputRules); err != nil { + return err + } + var input map[string]any var err error diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index b49eec8c6..2d676b8ab 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -422,6 +422,19 @@ func TestLoadPolicyHonorsHTTPProxy(t *testing.T) { require.True(t, sawProxyStyleRequest, "expected proxy to receive an absolute-URL request") } +func (suite *EvaluateInputCommandTestSuite) TestEvaluateInputCmdRefusesOutputRulesClashingWithOutputKeys() { + tests := []cmdTestCase{} + for _, name := range []string{"allow", "violations", "input", "params", "decision_attestation_id"} { + tests = append(tests, cmdTestCase{ + wantError: true, + name: "--output-rule " + name + " is refused", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-all.rego --output-rule " + name, + goldenRegex: `--output-rule cannot be '` + name + `', it is already part of the output`, + }) + } + runTestCmd(suite.T(), tests) +} + func TestEvaluateInputCommandTestSuite(t *testing.T) { suite.Run(t, new(EvaluateInputCommandTestSuite)) } From 278a467e15232db558f5a115bb048a38d43e3915 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:25:22 +0200 Subject: [PATCH 05/12] feat(evaluate): warn that --output-rule values only show with --output json Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateHelpers.go | 3 +++ cmd/kosli/evaluateInput_test.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index 4c7303c6f..4b198596e 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -553,6 +553,9 @@ func printEvaluateResult(out io.Writer, result *evaluate.Result, input map[strin for rule, value := range result.Outputs { auditResult[rule] = value } + if len(result.Outputs) > 0 && outputFormat == "table" { + logger.Warn("--output-rule values are only shown with --output json") + } // Absent everywhere else, so a caller reading a verdict alone parses the // same page as before. if decisionID != "" { diff --git a/cmd/kosli/evaluateInput_test.go b/cmd/kosli/evaluateInput_test.go index 2d676b8ab..d0e3d0d97 100644 --- a/cmd/kosli/evaluateInput_test.go +++ b/cmd/kosli/evaluateInput_test.go @@ -189,6 +189,12 @@ func (suite *EvaluateInputCommandTestSuite) TestEvaluateInputCmd() { {"report", nil}, }, }, + { + name: "--output-rule is left out of table output, with a hint", + cmd: "evaluate input --input-file testdata/evaluate/trail-input.json --policy testdata/policies/allow-with-report.rego --output-rule report", + goldenStdout: "RESULT: ALLOWED\n", + goldenStderr: "[warning] --output-rule values are only shown with --output json\n", + }, } runTestCmd(suite.T(), tests) } From 83cdc61129843d1942c30decc4c32bcc8ecd3093 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:36:30 +0200 Subject: [PATCH 06/12] feat(evaluate): add --output-rule to evaluate trail and trails Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateHelpers.go | 2 ++ cmd/kosli/evaluateInput.go | 4 +--- cmd/kosli/evaluateTrail.go | 6 +++++- cmd/kosli/evaluateTrail_test.go | 13 +++++++++++++ cmd/kosli/evaluateTrails.go | 6 +++++- cmd/kosli/evaluateTrails_test.go | 13 +++++++++++++ cmd/kosli/testdata/policies/trail-report.rego | 5 +++++ cmd/kosli/testdata/policies/trails-report.rego | 5 +++++ 8 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 cmd/kosli/testdata/policies/trail-report.rego create mode 100644 cmd/kosli/testdata/policies/trails-report.rego diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index 4b198596e..2a01ea5f3 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -64,6 +64,7 @@ type commonEvaluateOptions struct { assert bool noAssert bool serverSide bool + outputRules []string } func (o *commonEvaluateOptions) addFlags(cmd *cobra.Command, policyDesc string) { @@ -75,6 +76,7 @@ func (o *commonEvaluateOptions) addFlags(cmd *cobra.Command, policyDesc string) cmd.Flags().StringVar(&o.params, "params", "", policyParamsFlag) cmd.Flags().BoolVar(&o.assert, "assert", false, "[optional] Exit with a non-zero status when the policy denies. This is the current default; pass --assert to lock it in across future releases.") cmd.Flags().BoolVar(&o.noAssert, "no-assert", false, "[optional] Print the result and always exit 0, even when the policy denies. Use when this command feeds another tool as a policy decision point.") + cmd.Flags().StringSliceVar(&o.outputRules, "output-rule", nil, policyOutputRuleFlag) cmd.MarkFlagsMutuallyExclusive("assert", "no-assert") } diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index 53785b16a..02a1ebb79 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -12,8 +12,7 @@ import ( type evaluateInputOptions struct { commonEvaluateOptions - inputFile string - outputRules []string + inputFile string } const evaluateInputShortDesc = `Evaluate a local JSON input against a Rego policy.` @@ -96,7 +95,6 @@ func newEvaluateInputCmd(out io.Writer) *cobra.Command { o.addFlags(cmd, "Path or http(s):// URL of a Rego policy to evaluate against the input.") cmd.Flags().StringVarP(&o.inputFile, "input-file", "i", "", "[optional] Path to a JSON input file. Reads from stdin if omitted.") - cmd.Flags().StringSliceVar(&o.outputRules, "output-rule", nil, policyOutputRuleFlag) cmd.Flags().Lookup("flow").Hidden = true cmd.Flags().Lookup("attestations").Hidden = true diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index 229cb2d51..f0dd03aaa 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -109,6 +109,10 @@ func newEvaluateTrailCmd(out io.Writer) *cobra.Command { } func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { + if err := validateOutputRules(o.outputRules); err != nil { + return err + } + if o.serverSide { return evaluateServerSide(out, &o.commonEvaluateOptions, []evaluations.TrailRef{{Flow: o.flowName, Trail: args[0]}}) @@ -128,5 +132,5 @@ func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { "trail": trailData, } - return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), nil) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), o.outputRules) } diff --git a/cmd/kosli/evaluateTrail_test.go b/cmd/kosli/evaluateTrail_test.go index d60a5bc83..4b2bcfe78 100644 --- a/cmd/kosli/evaluateTrail_test.go +++ b/cmd/kosli/evaluateTrail_test.go @@ -150,6 +150,19 @@ func (suite *EvaluateTrailCommandTestSuite) TestEvaluateTrailCmd() { cmd: fmt.Sprintf(`evaluate trail %s --flow %s --policy testdata/policies/allow-all.rego --assert --no-assert %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), goldenRegex: `none of the others can be.*\[assert no-assert\] were all set`, }, + { + name: "--output-rule adds the rule to the JSON output", + cmd: fmt.Sprintf(`evaluate trail %s --flow %s --policy testdata/policies/trail-report.rego --output-rule report --output json %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"report.trail", suite.trailName}, + }, + }, + { + wantError: true, + name: "--output-rule clashing with an output key is refused", + cmd: fmt.Sprintf(`evaluate trail %s --flow %s --policy testdata/policies/allow-all.rego --output-rule allow %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), + goldenRegex: `--output-rule cannot be 'allow', it is already part of the output`, + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index 38fe7c9fe..498a81a5f 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -102,6 +102,10 @@ func newEvaluateTrailsCmd(out io.Writer) *cobra.Command { } func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { + if err := validateOutputRules(o.outputRules); err != nil { + return err + } + if o.serverSide { refs := make([]evaluations.TrailRef, 0, len(args)) for _, trailName := range args { @@ -128,5 +132,5 @@ func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { "trails": trails, } - return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), nil) + return evaluateAndPrintResult(out, o.policyRef, input, o.output, o.showInput, params, o.assertOnDeny(), o.outputRules) } diff --git a/cmd/kosli/evaluateTrails_test.go b/cmd/kosli/evaluateTrails_test.go index d26c14742..ec86c6fe1 100644 --- a/cmd/kosli/evaluateTrails_test.go +++ b/cmd/kosli/evaluateTrails_test.go @@ -109,6 +109,19 @@ func (suite *EvaluateTrailsCommandTestSuite) TestEvaluateTrailsCmd() { cmd: fmt.Sprintf(`evaluate trails %s --flow %s --policy testdata/policies/allow-all.rego --assert --no-assert %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), goldenRegex: `none of the others can be.*\[assert no-assert\] were all set`, }, + { + name: "--output-rule adds one rule value covering all the trails", + cmd: fmt.Sprintf(`evaluate trails %s %s --flow %s --policy testdata/policies/trails-report.rego --output-rule report --output json %s`, suite.trailName, suite.trailName2, suite.flowName, suite.defaultKosliArguments), + goldenJson: []jsonCheck{ + {"report.trails", 2.0}, + }, + }, + { + wantError: true, + name: "--output-rule clashing with an output key is refused", + cmd: fmt.Sprintf(`evaluate trails %s --flow %s --policy testdata/policies/allow-all.rego --output-rule allow %s`, suite.trailName, suite.flowName, suite.defaultKosliArguments), + goldenRegex: `--output-rule cannot be 'allow', it is already part of the output`, + }, } runTestCmd(suite.T(), tests) diff --git a/cmd/kosli/testdata/policies/trail-report.rego b/cmd/kosli/testdata/policies/trail-report.rego new file mode 100644 index 000000000..e4a0e480d --- /dev/null +++ b/cmd/kosli/testdata/policies/trail-report.rego @@ -0,0 +1,5 @@ +package policy + +allow = true + +report := {"trail": input.trail.name} diff --git a/cmd/kosli/testdata/policies/trails-report.rego b/cmd/kosli/testdata/policies/trails-report.rego new file mode 100644 index 000000000..2250763cb --- /dev/null +++ b/cmd/kosli/testdata/policies/trails-report.rego @@ -0,0 +1,5 @@ +package policy + +allow = true + +report := {"trails": count(input.trails)} From b11b4dedcd1e4725f852714916e59aeaeed7f31f Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:36:35 +0200 Subject: [PATCH 07/12] feat(evaluate): refuse --output-rule with --server-side The server only returns allow and violations for now. Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateHelpers.go | 5 +++++ cmd/kosli/evaluateServerSide_test.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index 2a01ea5f3..897fedc1c 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -374,6 +374,11 @@ func (o *commonEvaluateOptions) refuseWhatTheServerCannotDo() error { "--show-input is not supported with --server-side; " + "the server does not return the input it evaluated") } + if len(o.outputRules) > 0 { + return fmt.Errorf( + "--output-rule is not supported with --server-side; " + + "the server only returns allow and violations") + } return nil } diff --git a/cmd/kosli/evaluateServerSide_test.go b/cmd/kosli/evaluateServerSide_test.go index b8c8d4698..511cc2f5b 100644 --- a/cmd/kosli/evaluateServerSide_test.go +++ b/cmd/kosli/evaluateServerSide_test.go @@ -370,6 +370,11 @@ func (suite *EvaluateServerSideTestSuite) TestItRefusesWhatTheServerCannotDo() { extra: "--show-input", message: "--show-input is not supported with --server-side", }, + { + name: "adding policy rules to the output", + extra: "--output-rule report", + message: "--output-rule is not supported with --server-side", + }, } { suite.Run(test.name, func() { server, fake := newFakeEvaluations(suite.T(), verdictAllowed) From 51442172b6421deb9243ccbc9a350b3d65b93996 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:37:33 +0200 Subject: [PATCH 08/12] docs(evaluate): document --output-rule in help and examples Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluate.go | 1 + cmd/kosli/evaluateInput.go | 10 +++++++++- cmd/kosli/evaluateTrail.go | 12 +++++++++++- cmd/kosli/evaluateTrails.go | 12 +++++++++++- cmd/kosli/root.go | 2 +- 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/cmd/kosli/evaluate.go b/cmd/kosli/evaluate.go index 40cfa2140..62874b6c0 100644 --- a/cmd/kosli/evaluate.go +++ b/cmd/kosli/evaluate.go @@ -22,6 +22,7 @@ Use ` + "`evaluate input`" + ` to evaluate a local JSON file or stdin without an The policy must use ` + "`package policy`" + ` and define an ` + "`allow`" + ` rule. An optional ` + "`violations`" + ` rule (a set of strings) can provide human-readable denial reasons. +Use ` + "`--output-rule`" + ` to add other rules of the policy, like a report, to the JSON output. By default a deny exits with code 1 so the command can gate a pipeline. Pass ` + "`--no-assert`" + ` to use the command as a policy decision point: it prints diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index 02a1ebb79..61c36a6e7 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -25,6 +25,7 @@ the policy input from a ` + "`--show-input --output json`" + ` capture. The policy must use ` + "`package policy`" + ` and define an ` + "`allow`" + ` rule. An optional ` + "`violations`" + ` rule (a set of strings) can provide human-readable denial reasons. +Use ` + "`--output-rule`" + ` to add other rules of the policy, like a report, to the JSON output. By default a deny exits with code 1. Pass ` + "`--no-assert`" + ` to print the verdict and exit 0 even on deny, when this command is feeding another tool as a @@ -78,7 +79,14 @@ kosli evaluate input \ kosli evaluate input \ --input-file trail-data.json \ --policy policy.rego \ - --no-assert` + --no-assert + +# add the policy's report rule to the JSON output: +kosli evaluate input \ + --input-file trail-data.json \ + --policy policy.rego \ + --output-rule report \ + --output json` func newEvaluateInputCmd(out io.Writer) *cobra.Command { o := new(evaluateInputOptions) diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index f0dd03aaa..20a67c24b 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -15,7 +15,8 @@ The trail data is passed to the policy as ` + "`input.trail`" + `. Use ` + "`--attestations`" + ` to enrich the input with detailed attestation data (e.g. pull request approvers, scan results). Use ` + "`--show-input`" + ` to inspect the -full data structure available to the policy. Use ` + "`--output json`" + ` for structured output.` +full data structure available to the policy. Use ` + "`--output json`" + ` for structured output, +and ` + "`--output-rule`" + ` to add other rules of the policy, like a report, to it.` const evaluateTrailExample = ` # evaluate a trail against a policy: @@ -71,6 +72,15 @@ kosli evaluate trail yourTrailName \ --flow yourFlowName \ --no-assert \ --api-token yourAPIToken \ + --org yourOrgName + +# add the policy's report rule to the JSON output: +kosli evaluate trail yourTrailName \ + --policy yourPolicyFile.rego \ + --flow yourFlowName \ + --output-rule report \ + --output json \ + --api-token yourAPIToken \ --org yourOrgName` type evaluateTrailOptions struct { diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index 498a81a5f..d8c288cd8 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -16,7 +16,8 @@ The trail data is passed to the policy as ` + "`input.trails`" + ` (an array), u Use ` + "`--attestations`" + ` to enrich the input with detailed attestation data (e.g. pull request approvers, scan results). Use ` + "`--show-input`" + ` to inspect the -full data structure available to the policy. Use ` + "`--output json`" + ` for structured output.` +full data structure available to the policy. Use ` + "`--output json`" + ` for structured output, +and ` + "`--output-rule`" + ` to add other rules of the policy, like a report, to it.` const evaluateTrailsExample = ` # evaluate multiple trails against a policy: @@ -64,6 +65,15 @@ kosli evaluate trails yourTrailName1 yourTrailName2 \ --flow yourFlowName \ --no-assert \ --api-token yourAPIToken \ + --org yourOrgName + +# add the policy's report rule to the JSON output: +kosli evaluate trails yourTrailName1 yourTrailName2 \ + --policy yourPolicyFile.rego \ + --flow yourFlowName \ + --output-rule report \ + --output json \ + --api-token yourAPIToken \ --org yourOrgName` type evaluateTrailsOptions struct { diff --git a/cmd/kosli/root.go b/cmd/kosli/root.go index 8ee83602d..1f260478c 100644 --- a/cmd/kosli/root.go +++ b/cmd/kosli/root.go @@ -148,7 +148,7 @@ Paths the list already matches stay excluded whatever is later added there, so k outputFlag = "[defaulted] The format of the output. Valid formats are: [table, json]." serverSideFlag = "[hidden] Evaluate the policy on the Kosli server rather than on this machine. Unsupported and subject to change." policyParamsFlag = "[optional] Policy parameters as inline JSON or @file.json. Available in policies as data.params." - policyOutputRuleFlag = "[optional] Name of a policy rule to add to the JSON output, next to allow and violations." + policyOutputRuleFlag = "[optional] Name of a policy rule to add to the JSON output, next to allow and violations. Can be repeated, or given as a comma-separated list." policyAssertFlag = "[optional] Exit with a non-zero status when the policy denies. Without it the verdict is printed and the command exits 0." policyContextFlag = "What to evaluate, as trail=/. Repeat it to evaluate several trails at one instant." policyControlFlag = "[optional] Record the outcome as a decision against this control. Without it nothing is recorded." From 38eda39f8de64dc5431b0fb3ec373d3569efe57d Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Fri, 25 Sep 2026 10:39:25 +0200 Subject: [PATCH 09/12] test(evaluate): cover --output-rule in the empty-flag audit Co-Authored-By: Claude Opus 5.5 --- .../testdata/empty-flag-audit-coverage.json | 3 +++ hack/empty-flag-audit/spec.json | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/cmd/kosli/testdata/empty-flag-audit-coverage.json b/cmd/kosli/testdata/empty-flag-audit-coverage.json index 01381dd12..fcf6e3017 100644 --- a/cmd/kosli/testdata/empty-flag-audit-coverage.json +++ b/cmd/kosli/testdata/empty-flag-audit-coverage.json @@ -594,6 +594,7 @@ "input-file": "string", "no-assert": "bool", "output": "string", + "output-rule": "stringSlice", "params": "string", "policy": "string", "show-input": "bool" @@ -616,6 +617,7 @@ "flow": "string", "no-assert": "bool", "output": "string", + "output-rule": "stringSlice", "params": "string", "policy": "string", "server-side": "bool", @@ -627,6 +629,7 @@ "flow": "string", "no-assert": "bool", "output": "string", + "output-rule": "stringSlice", "params": "string", "policy": "string", "server-side": "bool", diff --git a/hack/empty-flag-audit/spec.json b/hack/empty-flag-audit/spec.json index 1ec2b7c7a..6deee67de 100644 --- a/hack/empty-flag-audit/spec.json +++ b/hack/empty-flag-audit/spec.json @@ -1669,7 +1669,7 @@ "args": [], "flags": { "input-file": "cmd/kosli/testdata/person-schema.json", - "policy": "cmd/kosli/testdata/policies/allow-all.rego" + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego" }, "baseline_ok": true, "baseline_exit": 0, @@ -1681,6 +1681,7 @@ "input-file", "no-assert", "output", + "output-rule", "params", "policy", "show-input" @@ -1692,8 +1693,9 @@ "input-file": "cmd/kosli/testdata/person-schema.json", "no-assert": "true", "output": "json", + "output-rule": "report", "params": "{}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego", + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego", "show-input": "true" }, "setup": [], @@ -1705,7 +1707,7 @@ ], "flags": { "flow": "{flow}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego" + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego" }, "baseline_ok": true, "baseline_exit": 0, @@ -1716,6 +1718,7 @@ "flow", "no-assert", "output", + "output-rule", "params", "policy", "server-side", @@ -1727,8 +1730,9 @@ "flow": "{flow}", "no-assert": "true", "output": "json", + "output-rule": "report", "params": "{}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego", + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego", "server-side": "false", "show-input": "true" }, @@ -1759,7 +1763,7 @@ ], "flags": { "flow": "{flow}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego" + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego" }, "baseline_ok": true, "baseline_exit": 0, @@ -1770,6 +1774,7 @@ "flow", "no-assert", "output", + "output-rule", "params", "policy", "server-side", @@ -1781,8 +1786,9 @@ "flow": "{flow}", "no-assert": "true", "output": "json", + "output-rule": "report", "params": "{}", - "policy": "cmd/kosli/testdata/policies/allow-all.rego", + "policy": "cmd/kosli/testdata/policies/allow-with-report.rego", "server-side": "false", "show-input": "true" }, From 560ca2ad7dedb1e15f2b28af0e9aea16d6d2ff39 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 28 Sep 2026 09:56:06 +0200 Subject: [PATCH 10/12] feat(evaluate): reject --output-rule names that are not rule identifiers Co-Authored-By: Claude Opus 5.5 --- internal/evaluate/rego.go | 6 ++++++ internal/evaluate/rego_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/internal/evaluate/rego.go b/internal/evaluate/rego.go index ed83ac83d..bfcb1b998 100644 --- a/internal/evaluate/rego.go +++ b/internal/evaluate/rego.go @@ -3,12 +3,15 @@ package evaluate import ( "context" "fmt" + "regexp" "github.com/open-policy-agent/opa/v1/ast" "github.com/open-policy-agent/opa/v1/rego" "github.com/open-policy-agent/opa/v1/storage/inmem" ) +var ruleName = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + // Result holds the outcome of a policy evaluation. type Result struct { Allow bool @@ -95,6 +98,9 @@ func validatePolicy(policySource string, outputRules []string) error { return fmt.Errorf("policy must declare an 'allow' rule") } for _, rule := range outputRules { + if !ruleName.MatchString(rule) { + return fmt.Errorf("'%s' is not a valid rule name", rule) + } if !declared[rule] { return fmt.Errorf("policy does not declare a '%s' rule", rule) } diff --git a/internal/evaluate/rego_test.go b/internal/evaluate/rego_test.go index af1603e03..70d2c8541 100644 --- a/internal/evaluate/rego_test.go +++ b/internal/evaluate/rego_test.go @@ -220,3 +220,28 @@ report := "never" if { require.NoError(t, err) require.Equal(t, map[string]any{"report": nil}, result.Outputs) } + +func TestEvaluate_OutputRuleDeclaredWithRefHead(t *testing.T) { + policy := `package policy + +allow = true + +report.summary := "all good" +` + result, err := Evaluate(policy, map[string]any{}, nil, "report") + require.NoError(t, err) + require.Equal(t, map[string]any{"report": map[string]any{"summary": "all good"}}, result.Outputs) +} + +func TestEvaluate_OutputRuleWithInvalidName(t *testing.T) { + policy := `package policy + +allow = true +` + for _, name := range []string{"", "report.summary", "report[0]", "1report"} { + t.Run(name, func(t *testing.T) { + _, err := Evaluate(policy, map[string]any{}, nil, name) + require.EqualError(t, err, "'"+name+"' is not a valid rule name") + }) + } +} From 09323eef482cf4fc37dd8eab0d5b8bad75eb5164 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 28 Sep 2026 09:56:38 +0200 Subject: [PATCH 11/12] test(evaluate): fail when the output gets a key --output-rule does not reserve Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateHelpers_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 cmd/kosli/evaluateHelpers_test.go diff --git a/cmd/kosli/evaluateHelpers_test.go b/cmd/kosli/evaluateHelpers_test.go new file mode 100644 index 000000000..5e331421d --- /dev/null +++ b/cmd/kosli/evaluateHelpers_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/kosli-dev/cli/internal/evaluate" + "github.com/stretchr/testify/require" +) + +func TestEvaluateResultKeysListsEveryKeyTheOutputCanHave(t *testing.T) { + var out bytes.Buffer + result := &evaluate.Result{Allow: true} + err := printEvaluateResult(&out, result, map[string]any{}, "json", true, map[string]any{}, false, "decision-id") + require.NoError(t, err) + + var printed map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &printed)) + for key := range printed { + require.Contains(t, evaluateResultKeys, key) + } +} From c087a308476136cf55cab95e8b2f37cdaf243080 Mon Sep 17 00:00:00 2001 From: Julien Biezemans Date: Mon, 28 Sep 2026 09:58:16 +0200 Subject: [PATCH 12/12] refactor(evaluate): check --output-rule in evaluateAndPrintResult Moves the clash check and the table warning there, and puts the printEvaluateResult doc comment back on its function. Co-Authored-By: Claude Opus 5.5 --- cmd/kosli/evaluateHelpers.go | 14 +++++++++----- cmd/kosli/evaluateInput.go | 4 ---- cmd/kosli/evaluateTrail.go | 4 ---- cmd/kosli/evaluateTrails.go | 4 ---- 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/cmd/kosli/evaluateHelpers.go b/cmd/kosli/evaluateHelpers.go index 897fedc1c..3c0bf7e6c 100644 --- a/cmd/kosli/evaluateHelpers.go +++ b/cmd/kosli/evaluateHelpers.go @@ -258,6 +258,10 @@ func parseParams(raw string) (map[string]any, error) { } func evaluateAndPrintResult(out io.Writer, policyRef string, input map[string]any, outputFormat string, showInput bool, params map[string]any, assertOnDeny bool, outputRules []string) error { + if err := validateOutputRules(outputRules); err != nil { + return err + } + policySource, err := loadPolicy(policyRef) if err != nil { return err @@ -267,6 +271,9 @@ func evaluateAndPrintResult(out io.Writer, policyRef string, input map[string]an if err != nil { return err } + if len(outputRules) > 0 && outputFormat == "table" { + logger.Warn("--output-rule values are only shown with --output json") + } return printEvaluateResult(out, result, input, outputFormat, showInput, params, assertOnDeny, "") } @@ -539,8 +546,6 @@ func policyBundleKey(ref string) string { return base } -// printEvaluateResult renders a verdict, whatever produced it, so that every -// evaluation path prints the same bytes for the same verdict. var evaluateResultKeys = []string{"allow", "violations", "input", "params", "decision_attestation_id"} func validateOutputRules(rules []string) error { @@ -552,6 +557,8 @@ func validateOutputRules(rules []string) error { return nil } +// printEvaluateResult renders a verdict, whatever produced it, so that every +// evaluation path prints the same bytes for the same verdict. func printEvaluateResult(out io.Writer, result *evaluate.Result, input map[string]any, outputFormat string, showInput bool, params map[string]any, assertOnDeny bool, decisionID string) error { auditResult := map[string]any{ "allow": result.Allow, @@ -560,9 +567,6 @@ func printEvaluateResult(out io.Writer, result *evaluate.Result, input map[strin for rule, value := range result.Outputs { auditResult[rule] = value } - if len(result.Outputs) > 0 && outputFormat == "table" { - logger.Warn("--output-rule values are only shown with --output json") - } // Absent everywhere else, so a caller reading a verdict alone parses the // same page as before. if decisionID != "" { diff --git a/cmd/kosli/evaluateInput.go b/cmd/kosli/evaluateInput.go index 61c36a6e7..d7f68bebf 100644 --- a/cmd/kosli/evaluateInput.go +++ b/cmd/kosli/evaluateInput.go @@ -116,10 +116,6 @@ func newEvaluateInputCmd(out io.Writer) *cobra.Command { } func (o *evaluateInputOptions) run(out io.Writer, in io.Reader) error { - if err := validateOutputRules(o.outputRules); err != nil { - return err - } - var input map[string]any var err error diff --git a/cmd/kosli/evaluateTrail.go b/cmd/kosli/evaluateTrail.go index 20a67c24b..fcaf9a66d 100644 --- a/cmd/kosli/evaluateTrail.go +++ b/cmd/kosli/evaluateTrail.go @@ -119,10 +119,6 @@ func newEvaluateTrailCmd(out io.Writer) *cobra.Command { } func (o *evaluateTrailOptions) run(out io.Writer, args []string) error { - if err := validateOutputRules(o.outputRules); err != nil { - return err - } - if o.serverSide { return evaluateServerSide(out, &o.commonEvaluateOptions, []evaluations.TrailRef{{Flow: o.flowName, Trail: args[0]}}) diff --git a/cmd/kosli/evaluateTrails.go b/cmd/kosli/evaluateTrails.go index d8c288cd8..11e6eb907 100644 --- a/cmd/kosli/evaluateTrails.go +++ b/cmd/kosli/evaluateTrails.go @@ -112,10 +112,6 @@ func newEvaluateTrailsCmd(out io.Writer) *cobra.Command { } func (o *evaluateTrailsOptions) run(out io.Writer, args []string) error { - if err := validateOutputRules(o.outputRules); err != nil { - return err - } - if o.serverSide { refs := make([]evaluations.TrailRef, 0, len(args)) for _, trailName := range args {