Skip to content

feat(evaluate): kosli evaluate policy, evaluating and recording a decision in one request - #1201

Merged
jumboduck merged 9 commits into
mainfrom
6920-evaluate-policy
Sep 17, 2026
Merged

jumboduck merged 9 commits into
mainfrom
6920-evaluate-policy

Conversation

@jumboduck

Copy link
Copy Markdown
Contributor

Implements kosli-dev/server#6920.

A new command evaluates a policy where the trail is stored and records the outcome as a decision in the same request. The decision value never passes through the CLI, which is what removes the self-reported step this replaces: evaluate, read allow out of a local report, then assert it back with kosli attest decision --compliant=<value>.

kosli evaluate policy \
    --context trail=my-release-flow/$GITHUB_SHA \
    --policy ./policies/SDLC-CTRL-0007-code-review \
    --control SDLC-CTRL-0007 \
    --fingerprint "$ARTIFACT_FINGERPRINT" \
    --params '{"protected_branch": "master"}' \
    --assert

What it does

  • --context trail=<flow>/<trail>, required and repeatable, names what is evaluated. Every trail travels in one evaluation, so they resolve at one instant.
  • --control records the outcome as a decision in --flow / --trail, under --name (default <control>-decision), about --fingerprint or about the trail itself. Without --control nothing is recorded, and the destination flags are not required.
  • --assert exits non-zero on a denial. Without it the verdict prints and the command exits 0: recording a decision is not a reason to fail the step that asked for it.
  • --policy takes a file or a directory. A directory travels as one bundle keyed by paths relative to it, with the published 100-file and 1 MiB caps refused here rather than sent to be rejected.
  • Output matches kosli evaluate trail, table and JSON, plus the written decision's id where there is one.
  • A denial is a decision and is recorded. A policy that could not run is not: it records nothing, is never worded as a denial, and fails.

Decisions worth a reviewer's attention

  • Hidden for now, against the ticket, which asks for a published command. Nothing can be exercised end to end until it runs against a server that can evaluate, and a listed command is a contract from the moment it ships. Unhiding is one line.
  • What is evaluated and where a decision lands are separate. --flow and --trail name only the destination, are satisfied by KOSLI_FLOW and KOSLI_TRAIL, and are never refused for being present without --control — a pipeline sets them for every command it runs.
  • No policy-bundle digest. It belongs with versioned policies, which are out of scope.
  • One exit code. The ticket asks for three; a single failure exit path is a product-wide convention, so the outcomes are told apart by what they say instead.
  • Synchronous only. No asynchronous mode and no --sync flag.
  • No http(s):// policies on this command, though the older evaluate commands keep them: that way of naming a policy is on its way out.
  • Refusals travel in the API's own words rather than being classified here, so no list of server cases has to be kept in step.

The request-and-verdict sequence is shared with the hidden --server-side flag on evaluate trail, so the two cannot drift.

Testing

  • New suites: the command (30 tests) and the decision block in the evaluations client (5 tests). They drive a stubbed server, because this repository's test environment has no evaluator to reach a verdict with.
  • make lint: 0 issues.
  • Not run: the full integration suite, which needs the local test server. Two older evaluate-trail suites and one HTTP client test are unverified for the same reason.
  • Still to do: a check against staging with an entitled organisation — allow, deny, a broken policy, a decision recorded and read back, and a destination the token cannot write to.

Plan and handover: docs/plans/6920-evaluate-policy.md, docs/handover/6920-evaluate-an-inline-policy-and-record-its-decis.md.

🤖 Generated with Claude Code

jumboduck and others added 8 commits September 17, 2026 11:20
Implements the planning step for kosli-dev/server#6920.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Evaluate a policy against a trail where the trail is stored, and print
the verdict. The request-and-verdict sequence is extracted from the
hidden --server-side path so both commands share it. No decision is
recorded yet, and asserting arrives with --assert in the next slice.

Slice 1 of kosli-dev/server#6920.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Exit with a non-zero status when the policy denies. Asserting is opt-in
here, the reverse of the other evaluate commands: recording a decision
is not a reason to fail the step that asked for it. An evaluation that
has not answered fails either way and is never reported as a verdict.

Slice 2 of kosli-dev/server#6920.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--context trail=<flow>/<trail>, repeated once per trail, is now the only
way to say what kosli evaluate policy evaluates, and it is required.
--flow and --trail return in the next slice as the decision's
destination alone: a pipeline sets them as environment variables for
every command, so their presence must not fail a run that asked for no
decision.

Slice 3 of kosli-dev/server#6920.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--control sends a decision block with the evaluation, so the outcome is
recorded where the policy runs and no verdict is asserted from here. The
name defaults to <control>-decision, and --flow and --trail name the
destination alone -- satisfied by KOSLI_FLOW and KOSLI_TRAIL, and never
refused for being present without a control. The written decision's id
is printed with the verdict.

Slice 4 of kosli-dev/server#6920.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A refusal reaches the caller as the status the API answered with and its
own message, and a policy that could not run is never worded as a
denial. The shared evaluation path already does this; these tests pin it
for kosli evaluate policy, so no list of server cases has to be kept in
step here.

Slice 5 of kosli-dev/server#6920.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--policy may name a directory, and every file below it travels keyed by
its path relative to that directory. Nothing is left out by name: what a
bundle may hold, and what its modules may import, is for the evaluator
that runs it to judge. The published file and byte caps, and an empty
directory, are still named here rather than sent to be refused.

A policy now comes from this machine only. Fetching one from a URL is on
its way out, so this command does not offer it.

Slice 6 of kosli-dev/server#6920.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The command is in no help listing and gets no docs page, though it runs
for anyone who names it. Nothing about it can be exercised end to end
until it runs against a server that can evaluate, and a listed command
is a contract from the moment it ships. The ticket asks for a published
command; unhiding it is one line, and it belongs with the wrap-up.

Slice 6 of kosli-dev/server#6920.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation go Pull requests that update go code feat labels Sep 17, 2026
@claude

claude Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jumboduck's task in 1m 34s —— View job


Follow-up review of commit 65d71d50

  • Fetch previous review findings and this PR's new commit diff
  • Check each previous finding against the new commit
  • Scan the new commit's changed lines for fresh issues
  • Post summary

Previous findings — status

# Finding Status
1 --fingerprint not validated before send (evaluatePolicy.go) Resolveddigest.ValidateDigest is now called in decision() before the evaluations.Decision is built (cmd/kosli/evaluatePolicy.go:194-198), with a new test case ("a fingerprint that is not a SHA256") asserting "not a valid SHA256" and that nothing is sent.
2 Symlinks followed / caps checked after full read (evaluateHelpers.go) ⚠️ Not addressed — this commit doesn't touch evaluateHelpers.go at all. The thread shows resolved on GitHub, but the walk still uses os.ReadFile on every non-dir DirEntry (no entry.Type().IsRegular() check) and still accumulates all files before checking maxPolicyBundleFiles/serverPolicyMaxBytes. Worth re-opening or explicitly deferring.
3 Refusal names only the flag that was typed (evaluatePolicy.go:172) Resolved — new namedDecisionFlags(name, fingerprint) helper builds the message from what was actually given; tests now assert both the mentioned flag and saysNot for the flag not given, plus a combined-both case.
4 --output validated too late, after the decision request (evaluatePolicy.go:161) Resolvedrun() now rejects an unknown --output value against evaluatePolicyOutputs ({"table": true, "json": true}) before parseTrailContexts/decision()/runServerEvaluation run, so a bad format never reaches the API. Matches the {"json", "table"} set actually wired in runServerEvaluation's output.FormattedPrint call, so no drift. New test: "an output format that does not exist".

New issues in the changed lines: none. The added validation is placed correctly (after the --flow/--trail presence check, before the fingerprint is put on the wire), and the new test cases match the behavior.

3 of 4 findings resolved; the symlink/streaming-caps finding in evaluateHelpers.go remains open and untouched by this commit.
· branch 6920-evaluate-policy

Comment thread cmd/kosli/evaluatePolicy.go
Comment thread cmd/kosli/evaluateHelpers.go
@mbevc1

mbevc1 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

@jumboduck could --context be something else than trail=...?

Comment thread cmd/kosli/evaluatePolicy.go
Comment thread cmd/kosli/evaluatePolicy.go
@jumboduck

jumboduck commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@jumboduck could --context be something else than trail=...?

@mbevc1 Not at the moment. We might have a better idea after Malmö.

A malformed fingerprint and an unknown output format are now refused
before the evaluation is created, and a decision flag without --control
is answered with the flag that was typed. The output format matters more
here than on the other evaluate commands: checked where it prints, a
typo would record the decision and then fail, and a rerun would record a
second one.

Raised in review on #1201.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mbevc1

mbevc1 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

@jumboduck could --context be something else than trail=...?

@mbevc1 Not at the moment. We might have a better idea after Malmö.

Gotcha, I was just wondering how come parameter is not just called --trail. Looking forward where we end up

@jumboduck
jumboduck merged commit 31dfa29 into main Sep 17, 2026
23 checks passed
@jumboduck
jumboduck deleted the 6920-evaluate-policy branch September 17, 2026 13:56
@jumboduck

jumboduck commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Gotcha, I was just wondering how come parameter is not just called --trail. Looking forward where we end up

@mbevc1 --trail is already used for the target of the decision attestation. You could evaluate one or several trails against a policy, and attest the decision on a completely different trail.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation feat go Pull requests that update go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants