Conversation
Adds the value of a policy rule to the JSON output, next to allow and violations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A declared rule with no value for the input prints null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t json Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server only returns allow and violations for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Claude finished @jbpros's task in 7m 21s —— View job Review:
|
| Check | Result |
|---|---|
go build ./... |
✅ clean |
go vet ./... |
✅ clean |
go mod tidy + git diff go.mod go.sum |
✅ no drift |
go test ./internal/evaluate/ |
go test is outside this job's allowed tools, so I reviewed the tests by reading them. The integration suites need the Docker Compose server anyway. |
Findings (all inline)
- Detached doc comment (
cmd/kosli/evaluateHelpers.go:542) —evaluateResultKeysandvalidateOutputRuleswere inserted between theprintEvaluateResultdoc comment and the function, so the comment now documents thevar. Suggestion attached. - Reserved-key list can drift (
evaluateHelpers.go:560) —evaluateResultKeyshand-duplicates the keysprintEvaluateResultwrites. Add a new output key later (asdecision_attestation_idrecently was) and an--output-ruleof that name passes validation, then gets silently clobbered — the rule loop runs before the reserved assignments, so the user's value vanishes with no error. The existing test repeats the same literal list, so it can't catch the drift. - Ref-head rules and empty names (
internal/evaluate/rego.go:90) —declaredis keyed onHead.Name, which isn't the plain name for a ref head likereport.summary := "x";--output-rule reportwould be refused even thoughdata.policy.reportevaluates fine. Report-library policies are exactly the ones likely to use ref heads. Related:--output-rule report,yields an empty name that flows toward the string-concatenated querydata.policy.. Validating names as identifiers up front closes both. - Recompiles the module per rule (
rego.go:54) —evalQuerybuilds a freshrego.Neweach call, so--output-rule a,b,ccompiles the policy 4–5 times. A singledata.policyquery, or a hoistedPrepareForEval, gets it to one compile. Not a blocker; matters most for the big report policies this feature targets.
Minor, not worth blocking: evaluateAndPrintResult / printEvaluateResult are up to 8 positional params — an options struct would read better next time one is added. printEvaluateResult also emits the --output-rule warning, which is dead on the server-side path that shares it; evaluateAndPrintResult would be a more natural home.
What's good
- The slicing is exemplary: nine commits, each one capability (add the flag → repeat/comma → undeclared rule → clash guard → table warning → server-side refusal → docs → audit), each green on its own. Matches the CLAUDE.md workflow closely.
- Test coverage is genuinely thorough for the interesting axes — allow and deny, repeated and comma-separated, declared-but-undefined giving
null, table output warning on stderr, every reserved name refused via a table-driven subtest, and the server-side refusal alongside the existing--attestations/--show-inputones. - The
evalQueryextraction is a real cleanup: it removed the duplicatedrego.New+ store setup betweenEvaluateandcollectViolationsrather than adding a third copy. - Refusing
--server-sidewith a message that says why (the server only returns allow and violations) follows the establishedrefuseWhatTheServerCannotDoconvention exactly, and thehack/empty-flag-audit/spec.jsonupdate keeps the flag under audit instead of quietly exempting it. - Clean call on rejecting rule names that collide with output keys instead of namespacing them — the output stays flat and readable, and the failure is loud.
None of the above blocks merge; item 3 is the one I'd want a test for before shipping, since it decides whether real report policies work.
· branch feat/evaluate-output-rule
| // 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 | ||
| } | ||
|
|
There was a problem hiding this comment.
The printEvaluateResult doc comment is now detached from the function it documents — evaluateResultKeys and validateOutputRules were inserted between them, so godoc now reads the comment as documenting the var.
| // 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 | |
| } | |
| var evaluateResultKeys = []string{"allow", "violations", "input", "params", "decision_attestation_id"} | |
| // validateOutputRules refuses a rule name that printEvaluateResult already | |
| // uses as an output key, so a policy rule can never shadow the verdict. | |
| 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 | |
| } | |
| // printEvaluateResult renders a verdict, whatever produced it, so that every | |
| // evaluation path prints the same bytes for the same verdict. |
| 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") | ||
| } |
There was a problem hiding this comment.
evaluateResultKeys is a hand-maintained copy of the keys this function writes, and nothing ties the two together. decision_attestation_id was itself a recent addition; the next output key added below will pass validateOutputRules unnoticed, and because this rule loop runs before the reserved keys are assigned, the reserved key silently wins and the user's --output-rule value disappears with no error.
Cheapest guard is a test that asserts every key printEvaluateResult can emit (verdict + decision id + --show-input + --params) is present in evaluateResultKeys; the test in evaluateInput_test.go currently repeats the same literal list, so it can't catch the drift. A comment here pointing at evaluateResultKeys would help too.
| 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 | ||
| } |
There was a problem hiding this comment.
Each output rule re-parses and re-compiles the whole module: evalQuery builds a fresh rego.New(...) every call, so --output-rule a,b,c compiles the policy 4–5 times (allow, violations, plus one per rule). For the evidence-report policies this feature exists for (large modules, often the whole rego evidence-report library bundled in), compilation dominates evaluation time.
Two cheaper shapes:
rego.New(rego.Query("data.policy"), ...)once, then pickallow/violations/ each output rule out of the returned object — one compile, one eval;- or keep per-rule queries but hoist a
rego.PrepareForEvalof the compiled module and reuse the prepared query.
Not a blocker at today's rule counts, but worth a follow-up.
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
Two edge cases the declared map doesn't cover, both worth a test in rego_test.go:
- Ref-head rules.
Head.Nameis only the plain rule name for single-var heads; for a ref head such asreport.summary := "x"the name is notreport, so--output-rule reportis rejected even thoughdata.policy.reportexists and evaluates fine. Usingrule.Head.Ref()(first term) rather thanHead.Namewould cover both spellings. Policies built from a report library are exactly the ones likely to use ref heads. - Empty rule names.
--output-rule report,yields["report", ""]fromStringSliceVar. Today that fails withpolicy does not declare a '' rule(acceptable), but if any parse path ever puts""indeclared, the empty name flows intoevalQueryas the querydata.policy.and surfaces as a raw Rego parse error. Rejecting a non-identifier rule name up front invalidateOutputRuleswould close both the ugly message and the string-concatenated query.
The concatenation at line 69 ("data.policy."+rule) is safe only because an arbitrary string can't match a declared rule name — a name check would make that explicit rather than incidental.
| if err := validateOutputRules(o.outputRules); err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
This guard is now repeated verbatim in all three run() methods (evaluateInput.go:119, evaluateTrail.go:122, evaluateTrails.go:115), while the shared choke point evaluateAndPrintResult — which every one of them funnels into — doesn't check. A fourth evaluate subcommand that embeds commonEvaluateOptions gets the flag from addFlags for free but silently loses the clash check unless the author remembers this line.
Moving the call into evaluateAndPrintResult (and leaving refuseWhatTheServerCannotDo to handle --server-side) makes the guard structural. One behaviour change to note either way: --server-side --output-rule allow currently reports the clash rather than the more useful "not supported with --server-side".
|
@jbpros rebaser to |
Adds
--output-ruletokosli evaluate input|trail|trails. It puts extra policy rules in the JSON output, next toallowandviolations.Why: a policy can build a full evidence report (like the rego evidence report library does), but
kosli evaluatedrops everything exceptallowandviolations. The only workaround so far was smuggling JSON strings throughviolations.How it behaves
--output-rule report,summary.null.allow,violations,input,params,decision_attestation_id) are refused.--output json.Not doing now
--server-side: refused for now, because the opa-lambda only returnsallowandviolations. Follow-up: Support --output-rule with --server-side #1215.--policystill loads a single module, so a library has to be bundled intopackage policy. To be fixed separately.Checklist
charts/k8s-reporter/) updated, if needed. Note: these changes live in a separate PR🤖 Generated with Claude Code