Skip to content

[SG-320] feat(local): use tirith -policy-path as a CI gate — a directory of policies, masked input, and the verdict written out - #283

Closed
refeed wants to merge 2 commits into
mainfrom
feat/local-check
Closed

[SG-320] feat(local): use tirith -policy-path as a CI gate — a directory of policies, masked input, and the verdict written out#283
refeed wants to merge 2 commits into
mainfrom
feat/local-check

Conversation

@refeed

@refeed refeed commented Aug 18, 2026

Copy link
Copy Markdown
Member

Why

Local, credential-free policy evaluation has existed for a while, but only inside the GitHub Action
(scripts/tirith_action/local.py, 360 lines). The GitLab CI component this unblocks would have had to
fork it — which is exactly what that module's own docstring says it exists to avoid. So it moves into
the CLI, and both front ends drive one implementation.

It moves onto the command that already does this, tirith -policy-path … -input-path …, as flags —
not into a subcommand of its own. Two surfaces for one task drift, and the one people already know is
the one worth improving.

tirith -policy-path .tirith/policies -input-path plan.json \
       --input-kind terraform_plan --output-json result.json --output-markdown report.md \
       --fail-on-error

What's new on the flat surface

-policy-path accepts a directory or a glob and evaluates every policy it finds — recursively for
*.tirith.json, or failing that any .json file shaped like a policy. This is new capability, not
changed behaviour: a directory previously reached open() and failed with a bare ERROR. The shape
test is load-bearing rather than defensive — a policy directory routinely also holds plan.json, and
without it the plan is evaluated as a policy, reporting a spurious failure that buries the real
findings. -var-path and -var apply to every policy, so a parameterised policy behaves the same
whether you name the file or the directory holding it.

--input-kind masks the document before evaluation. This matters even though nothing is uploaded:
evaluator messages embed the values they compared, and those messages are copied verbatim into whatever
comment a CI job posts, so an unmasked run publishes plan values to a code host. It also keeps a local
verdict identical to platform check's for the same plan, because that path evaluates the masked
document too.

--output-json / --output-markdown / --comment-marker / --markdown-limit / --sha write the
verdict out in the same shapes platform check writes, so one CI integration drives either.

Keeping the frozen contract frozen

This command's --json stdout is pinned byte-for-byte by tests/core/test_output_compatibility.py, and
masking changes the evaluator messages that document contains. So:

  • masking is opt-in via --input-kind rather than automatic;
  • the routing is deliberately narrow — a single policy file with none of the new flags reaches the
    original code path untouched. A test pins that in both directions.

One result document, two modes

Both modes now build --output-json through report.result_document, so the shapes cannot drift by
editing one caller. Every key exists in every mode; a key with no meaning on a path is null rather
than omitted or invented. Omission forces every consumer to write two parsers, and a fabricated
wfrun_url renders as a link to a run that does not exist. Specified in docs/output-contract.md.

Two consequent changes on the platform side, both additive and both failing closed:

  • the pre-poll write is a full document instead of three keys, so a consumer reading it after a timeout
    meets no partial shape;
  • CheckError paths write both output files rather than returning having written nothing. A caller
    editing a sticky comment in place needs a marker-first body there or it orphans the comment it was
    updating — that has happened. render_markdown gained an optional notes= so the report can say
    why, instead of claiming "the workflow run finished as ERRORED" in a mode with no run.

Gating

0, 3 with --fail-on-error, 1 for anything that produced no verdict. Three paths fail closed
regardless of the flag, because all three are tool health rather than policy decisions:

  • no policy files found at -policy-path — a green result for a change nothing was evaluated against is
    the one outcome this must never produce;
  • a policy that could not be evaluated at all;
  • a run where every check was skipped. Nothing was examined, and this command has always called that
    a failure rather than a pass.

That last one is a deliberate difference from platform check, which counts skips separately and reports
them as a pass. Both readings are defensible; a single surface disagreeing with itself depending on how
many policies you pointed it at is not. Pinned by a test and documented.

Verification

  • 39 new tests in tests/local/; 729 passing overall. The 11 failures on this branch are
    pre-existing on main — they need a terraform binary.
  • The golden --json contract and tests/cli/ pass unchanged.
  • black==25.1.0 (the version CI pins) and pydocstyle clean.
  • npm run build in documentation/ succeeds. The new page's flag block is fenced rather than indented
    because MDX dropped indented code blocks and {terraform_plan,…} would otherwise parse as JSX — that
    failed the build once here.
  • Two doc-currency guards keep docs/evaluating-policy-files.md from rotting the way the older pages
    did. One caught a missing flag while this was being written.
  • Exercised by hand: the old single-file invocation byte-for-byte unchanged, a directory evaluating
    where it used to error, exit 3 under --fail-on-error, exit 1 with no policies, and a
    marker-first body on every path.

What this unblocks

Tag 1.3.0, then the GitLab CI/CD component
pins it and the action's local.py is deleted in favour of it. The component is already built and its
self-test is green against this branch, pinned by commit sha until the tag exists.

Note SonarCloud fails here, as it does on #272, #279 and #280 — all merged.

…cal check`

Local, credential-free policy evaluation has existed for a while, but only inside the
GitHub Action (`scripts/tirith_action/local.py`). A second CI front end — the GitLab
component this unblocks — would have had to fork it, which is exactly what that module's
own docstring says it exists to avoid. So it moves here, and both front ends drive one
implementation.

A sibling subcommand rather than a flag on `platform check`. That command is documented
top to bottom as "masks, packs, uploads, runs on StackGuardian, polls"; a path doing none
of those under the same verb makes its help text wrong for half its readers. Mechanically
it would be worse: `--workflow-id` is `required=True` there, so a flag would have to lift
that requirement out of argparse into a hand-rolled conditional, weakening the platform
path to accommodate a mode with no workflows — and ~20 other flags would become no-ops
needing per-flag conflict handling. `platform check` gains no flag at all, so its help
block and reference page are byte-identical.

Local mode is never entered implicitly. `platform check` with no credentials stays a hard
error rather than falling back, because a fallback evaluates whatever happens to be
committed and reports green when a token was simply misspelled. A front end that wants "no
credentials therefore local" chooses that itself. For the same reason the command rejects
every credential, workflow, archive and run flag instead of accepting and ignoring them.

One result document, two modes
------------------------------
Both modes now build `--output-json` through `report.result_document`, so the shapes cannot
drift by editing one caller. Every key exists in every mode; a key with no meaning on a
path is null rather than omitted or invented — omission forces every consumer to write two
parsers, and a fabricated `wfrun_url` links to a run that does not exist. A `mode`
discriminator says which path ran. Specified in docs/output-contract.md.

Two consequent changes on the platform side, both additive and both failing closed:

  * The pre-poll write is now a full document instead of three keys, so a consumer reading
    it after a timeout meets no partial shape.
  * `CheckError` paths write both output files rather than returning having written
    nothing. A caller editing a sticky comment in place needs a marker-first body there or
    it orphans the comment it was updating — that has happened. `render_markdown` gained an
    optional `notes=` so the report can say why, instead of claiming "the workflow run
    finished as ERRORED" in a mode that has no run.

Gating
------
Exit codes mirror `platform check`: 0, 3 with `--fail-on-error`, 1 for anything that
produced no verdict. Two paths fail closed regardless of the flag, because both are tool
health rather than policy decisions: no policy files found at `--policy-path`, and a policy
that could not be evaluated at all. A green result for a change nothing was evaluated
against is the one outcome this mode must never produce.

Recorded rather than fixed: a policy whose every check was skipped reports `passed` here,
matching platform mode, while the flat surface exits 1 for the same policy on the grounds
that "nothing ran" is not a pass. Both readings are defensible, so the divergence is pinned
by a test to make it visible instead of letting either surface drift into the other. It
needs one decision across all three surfaces.
@notion-workspace

Copy link
Copy Markdown

@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.84211% with 66 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/tirith/local/evaluate.py 71.77% 33 Missing and 13 partials ⚠️
src/tirith/local/check.py 87.83% 6 Missing and 3 partials ⚠️
src/tirith/platform/check.py 66.66% 8 Missing and 1 partial ⚠️
src/tirith/platform/cli.py 50.00% 1 Missing ⚠️
src/tirith/platform/report.py 85.71% 0 Missing and 1 partial ⚠️
Files with missing lines Coverage Δ
src/tirith/__init__.py 100.00% <100.00%> (ø)
src/tirith/cli.py 79.56% <100.00%> (+5.17%) ⬆️
src/tirith/platform/cli.py 66.42% <50.00%> (-0.49%) ⬇️
src/tirith/platform/report.py 94.21% <85.71%> (+1.81%) ⬆️
src/tirith/local/check.py 87.83% <87.83%> (ø)
src/tirith/platform/check.py 57.33% <66.66%> (+6.12%) ⬆️
src/tirith/local/evaluate.py 71.77% <71.77%> (ø)

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…nstead of a subcommand

Replaces the `tirith local check` subcommand added in the previous commit. Evaluating
policy files committed in a repository is what `tirith -policy-path ... -input-path ...`
has always done, so the capability a CI job needs belongs on that command as flags rather
than in a second command doing the same job slightly differently. Two surfaces for one
task drift, and the one people already know is the one to improve.

Three additions, all optional and all off unless asked for:

  * `-policy-path` accepts a directory or a glob and evaluates every policy it finds. This
    is new capability rather than changed behaviour: a directory previously reached
    `open()` and failed with a bare `ERROR`. `-var-path` and `-var` apply to every policy,
    so a parameterised policy behaves the same whether you name the file or its directory.

  * `--input-kind` masks the document before evaluation. It matters even with nothing
    uploaded, because evaluator messages embed the values they compared and those messages
    are copied into whatever comment a CI job posts.

  * `--output-json`, `--output-markdown`, `--comment-marker`, `--markdown-limit` and
    `--sha` write the verdict out in the shapes `platform check` already writes, so one CI
    integration drives either.

Keeping the frozen contract frozen
----------------------------------
This command's `--json` stdout is pinned byte-for-byte by
tests/core/test_output_compatibility.py, and masking changes the evaluator messages that
document contains. So the routing is deliberately narrow: a single policy file with none
of the new flags reaches the original code path untouched, which is why masking is opt-in
rather than automatic. A test pins that routing in both directions.

The all-skipped question, now decided
-------------------------------------
The previous commit left this open, because the subcommand and the flat surface disagreed:
a policy whose every check was skipped reported `passed` in one and exited 1 in the other.
With one surface there is one answer, and it is the released one -- exit 1, "nothing was
examined is not a pass". The report says so in words rather than rendering a green verdict
beside a red exit code.

This remains a deliberate difference from `platform check`, which counts skips separately
and reports them as a pass. Both readings are defensible; a single surface disagreeing
with itself depending on how many policies you pointed it at is not.

Docs
----
docs/local-check.md becomes docs/evaluating-policy-files.md, and the two doc-currency
guards in tests/test_readme_is_current.py follow it -- one of them caught a missing flag
while this was being written. The Usage block in the README is regenerated from the real
--help, and `--input-kind` takes a `KIND` metavar because spelling its four choices out
pushed every option's help text far to the right.
@sonarqubecloud

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@refeed refeed changed the title [SG-320] feat(local): evaluate committed policy files with tirith local check [SG-320] feat(local): use tirith -policy-path as a CI gate — a directory of policies, masked input, and the verdict written out Aug 18, 2026
@refeed

refeed commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Closing unmerged, in favour of duplicating this logic into the GitLab CI component
(gitlab.com/stackguardian/tirith-iac-governance)
and pinning the released 1.2.0 instead. That keeps the GitLab work independent of a CLI release.

Recording what was here, in case it is wanted later:

  • -policy-path accepting a directory or a glob rather than a single file. Pointing it at a directory
    currently reaches open() and fails with a bare ERROR, so this remains a real gap in the CLI
    regardless of where the CI logic lives.
  • --input-kind masking the document before evaluation, opt-in because masking changes the evaluator
    messages that make up the golden-pinned --json output.
  • --output-json / --output-markdown / --comment-marker / --markdown-limit / --sha.
  • report.result_document, one builder for both modes so the two documents cannot drift.
  • render_markdown(notes=…), so a report with no verdict can say why instead of claiming "the workflow
    run finished as ERRORED" on a path that has no run.
  • platform check writing both output files on its failure paths, so a caller editing a sticky comment
    in place has a marker-first body there and does not orphan the comment.

The last three are platform-mode improvements that stand on their own merits and are not GitLab-specific.
The branch feat/local-check is left in place at 5531d86 if any of it is worth reviving.

@refeed refeed closed this Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant