Skip to content

feat(platform): add tirith platform check - #272

Open
refeed wants to merge 5 commits into
mainfrom
feat/gate-capable-engine
Open

feat(platform): add tirith platform check#272
refeed wants to merge 5 commits into
mainfrom
feat/gate-capable-engine

Conversation

@refeed

@refeed refeed commented Aug 3, 2026

Copy link
Copy Markdown
Member

Adds a platform subcommand that runs an organization's StackGuardian policies against a plan,
state or arbitrary JSON document — from CI or a laptop:

tirith platform check --org acme --workflow-id infra --input-path plan.json --fail-on-error

It masks the document locally, packs it with the terraform source into an archive, uploads it,
creates a StackGuardian run, polls it, and reports the verdict as JSON and/or markdown.

This moves the StackGuardian protocol out of the GitHub Action, where it was GitHub-only,
untestable off a runner, and unavailable to anyone driving the platform from GitLab or a Makefile.

No new runtime dependencies — the whole thing is stdlib urllib, so a runner needs nothing
beyond tirith itself.

The local surface is untouched

Subcommands are dispatched before the flat parser sees anything. argparse cannot express an optional
subcommand alongside options like -policy-path, and tests/core/test_output_compatibility.py
asserts the --json output byte-for-byte because the platform and the step templates parse it.

Also fixes cli.main(args=...), which was silently ignored: parse_args() was called with no
argument, so the CLI could only ever read sys.argv. That is why there were no CLI tests.

Two bugs found while writing this

  • APPROVAL_REQUIRED was missing from the poller's terminal statuses. It is a resting state, so
    a run that reached it spun until the timeout and was then reported as a tool failure — an outage,
    rather than a finished evaluation waiting on a human. It now yields an approval-required verdict.
  • A file named state.json in the working directory was packed raw.
    terraform state pull > state.json is the documented way to produce one, so it routinely sits
    there unmasked — and it shipped in full, beside the masked copy. plan.json / state.json /
    infracost.json are now always written by pack() from an already-masked object and never copied
    from the source tree.

The archive tests assert on the bytes inside the built tarball, not on the objects handed to
pack(). That distinction is the point: testing the masking function in isolation is what let a
leak through once already.

Exit codes

0 clean · 3 a policy failed under --fail-on-error · 1 unreachable platform or no verdict —
the last regardless of the flag, because a run that produced no verdict must never look like a pass.

335 tests pass (245 baseline + 90 new).

Companion PRs: StackGuardian/core#1235, StackGuardian/api#1708, StackGuardian/auth#201,
StackGuardian/workflow-step-templates#310, StackGuardian/sg-cli-gh-action.

refeed added 2 commits August 1, 2026 18:11
Fixed:
- Variable substitution mutated the caller's policy dict. Evaluating the same
  parsed policy twice (a policy set, or a retry) leaked substituted values from
  one evaluation into the next.
- An unsupported condition.type returned without setting result["result"],
  raising KeyError in the pretty printer far from the real cause. The consumer
  is hardened with .get("result", []) as well.
- Provider errors reported without a ProviderError severity were discarded and
  None was evaluated against the condition, so a typo'd operation_type read as
  a genuine policy violation. Five sites across four providers were affected.
  These are malformed provider calls, so they deliberately bypass
  error_tolerance -- that setting exists to tolerate missing data, not to mask
  a broken policy.

Added:
- meta.id/name/description/severity/enforcement/tags/remediation now reach the
  result document when declared. Absent keys are omitted, so output for a
  policy declaring none of them is unchanged.

Backward compatibility is pinned by tests/golden/json_policy_output.json,
captured before these changes and asserted byte-identical after them.
Runs an organization's policies against a plan, state or arbitrary JSON
document from CI or a laptop: masks the document locally, packs it with the
terraform source into an archive, uploads it, creates a StackGuardian run, polls
it and reports the verdict as JSON and/or markdown.

This moves the StackGuardian protocol out of the GitHub Action, where it was
GitHub-only, untestable off a runner, and unavailable to anyone driving the
platform from GitLab or a Makefile. No new runtime dependencies -- the whole
thing is stdlib urllib, so a runner needs nothing beyond tirith itself.

Subcommands are dispatched before the flat parser sees anything. argparse cannot
express an optional subcommand alongside options like `-policy-path`, and the
local-evaluation surface is a contract that test_output_compatibility.py asserts
byte-for-byte. Also fixes cli.main(args=...), which was ignored because
parse_args() was called with no argument.

Two bugs found while writing this:

  * APPROVAL_REQUIRED was missing from the poller's terminal statuses. It is a
    resting state, so a run that reached it spun until the timeout and was then
    reported as a tool failure -- an outage, rather than a finished evaluation
    waiting on a human. It now yields an `approval-required` verdict.
  * A file named state.json in the working directory was packed raw.
    `terraform state pull > state.json` is the documented way to produce one, so
    it routinely sits there unmasked, and it shipped in full beside the masked
    copy. plan.json / state.json / infracost.json are now always written by
    pack() from an already-masked object and never copied from the source tree.

Exit codes: 0 clean, 3 for a policy failure under --fail-on-error, 1 for an
unreachable platform or a run that produced no verdict -- the last regardless of
the flag, because a run with no verdict must never look like a pass.
@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 68.05737% with 245 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/tirith/platform/check.py 16.66% 80 Missing ⚠️
src/tirith/platform/client.py 53.42% 57 Missing and 11 partials ⚠️
src/tirith/platform/cli.py 59.04% 41 Missing and 2 partials ⚠️
src/tirith/platform/redact.py 81.72% 17 Missing and 17 partials ⚠️
src/tirith/platform/report.py 89.07% 5 Missing and 8 partials ⚠️
src/tirith/platform/archive.py 93.61% 4 Missing and 2 partials ⚠️
src/tirith/prettyprinter.py 0.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/tirith/__init__.py 100.00% <100.00%> (ø)
src/tirith/cli.py 60.00% <100.00%> (+60.00%) ⬆️
src/tirith/core/core.py 85.71% <100.00%> (+3.49%) ⬆️
src/tirith/core/policy_parameterization.py 100.00% <100.00%> (ø)
src/tirith/status.py 100.00% <100.00%> (+100.00%) ⬆️
src/tirith/prettyprinter.py 51.48% <0.00%> (+51.48%) ⬆️
src/tirith/platform/archive.py 93.61% <93.61%> (ø)
src/tirith/platform/report.py 89.07% <89.07%> (ø)
src/tirith/platform/redact.py 81.72% <81.72%> (ø)
src/tirith/platform/cli.py 59.04% <59.04%> (ø)
... and 2 more

... and 1 file with indirect coverage changes

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new tirith platform check subcommand that runs StackGuardian policy evaluations against a plan/state/JSON document by packaging masked inputs + Terraform source into an archive, creating/polling a StackGuardian run, and emitting JSON/markdown verdict output. It also tightens several core/CLI behaviors to preserve existing output contracts and avoid previously observed failure/leak modes.

Changes:

  • Add a stdlib-only StackGuardian “platform” integration (client, check, archive, redact, report) plus extensive tests for polling, masking, archiving, and rendering.
  • Add CLI subcommand pre-dispatch (tirith platform ...) while preserving the legacy flat CLI surface and byte-identical --json output compatibility.
  • Fix core behaviors (policy var substitution mutability, unsupported evaluator result shape, provider bare error surfacing) and bump version/changelog.

Reviewed changes

Copilot reviewed 24 out of 24 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/platform/test_report.py Tests for verdict computation and markdown rendering/truncation behavior.
tests/platform/test_redact.py Security-focused tests asserting redaction on serialized bytes for plan/state.
tests/platform/test_client.py Tests for StackGuardian client polling/terminal states and upload behavior.
tests/platform/test_archive.py Tests archive contents/exclusions and ensures masked docs win over disk files.
tests/golden/json_policy_output.json Golden output fixture used to pin legacy JSON byte compatibility.
tests/core/test_policy_parameterization.py Adds regression tests ensuring var substitution doesn’t mutate caller policy dict.
tests/core/test_output_compatibility.py New contract tests ensuring stable output shape/bytes for consumers.
tests/core/test_core.py Adds tests for unsupported evaluator result shape and provider bare error surfacing.
tests/cli/test_dispatch.py Tests for subcommand dispatch without breaking legacy flat CLI contract.
src/tirith/status.py Adds distinct exit code for policy-failed outcomes under --fail-on-error.
src/tirith/prettyprinter.py Avoids KeyError by tolerating missing result key in evaluator output.
src/tirith/platform/report.py Implements result summarization, verdict mapping, and markdown rendering.
src/tirith/platform/redact.py Implements plan slimming + marker-driven redaction and state masking.
src/tirith/platform/client.py Implements stdlib-only StackGuardian API client including polling and artifact fetch.
src/tirith/platform/cli.py Implements tirith platform argparse surface and exit-code semantics.
src/tirith/platform/check.py Orchestrates read→mask→pack→upload→run→poll→fetch→report flow.
src/tirith/platform/archive.py Builds tar.gz archive with exclusions and reserved-name handling.
src/tirith/platform/init.py Introduces platform package with stdlib-only intent documented.
src/tirith/core/policy_parameterization.py Switches var substitution to operate on a deep copy to avoid mutation leaks.
src/tirith/core/core.py Ensures unsupported evaluator still populates result; passes through policy meta keys.
src/tirith/cli.py Adds pre-dispatch for subcommands and fixes main(args=...) honoring provided argv.
src/tirith/init.py Version bump to 1.2.0.
setup.py Updates package version to 1.2.0.
CHANGELOG.md Documents 1.2.0 release changes and notes/contracts.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/tirith/platform/report.py Outdated
Comment on lines +107 to +110
if counts.get(WARN) or counts.get(APPROVAL_REQUIRED):
return "warned"
if counts.get(PASS) or counts.get("SKIPPED"):
return "passed"
Comment on lines +176 to +179
# The masked documents are written separately and must win.
if relative in reserved_names:
skipped += 1
continue
f"The upload response for {filename} carried no storage key. The platform may "
f"predate the configuration_upload_url endpoint. Response: {payload}"
)
signed_url = _extract_signed_url({"msg": msg.get("signedUrl")})
refeed added 3 commits August 3, 2026 12:35
A third instance of the `planned_values` pattern, caught by a live GitHub Action
run: a hardcoded value is masked in `resource_changes` and sits in plaintext in
the same document under
`configuration.root_module.resources[].expressions[].constant_value`, which
carries no sensitivity markers at all.

`configuration` cannot be dropped -- three operations read it -- so the literals
are scrubbed while the reference graph is kept. Lossless:
direct_references_operator reads only `references` and
direct_dependencies_operator only `depends_on`
(providers/terraform_plan/handler.py:329, :385-388).

Covers nested block arguments, repeated blocks (a list of expressions), child
modules via module_calls[].module, and variable `default` / output `expression`
literals.

Note this does not make a plan safe to hand out: the project archive carries the
terraform source as written, so a secret hardcoded in HCL still reaches the
platform in main.tf. Documented in the action's README rather than papered over.
…c tfstate.json

sensitive_attributes is a list of PATHS -- each entry is itself a list of steps:

    [[{"type": "get_attr", "value": "content_base64"}],
     [{"type": "get_attr", "value": "content"}]]

The code read only the flat forms, so on real state every entry was skipped: a
list is neither a dict nor a string. Nothing in a resource's attributes was
masked at all. The unit test passed because its fixture invented the flat shape;
verified now against `terraform state pull` output for a local_sensitive_file,
which is where the real shape came from.

Paths can also descend through nested objects and list indices, so the masker
walks them rather than assuming a single key, and deep-copies so the caller's
document is not mutated underneath it.

Renames the archive's state document from state.json to tfstate.json, matching
the TfStateCleaned fact it feeds and the name the terraform step already uses
for state. No collision: the archive unpacks into the user directory, while
managed state lives at the artifacts root, and policy-only forces
managedTerraformState off.
A rule result of APPROVAL_REQUIRED means its author wrote
`onFail: APPROVAL_REQUIRED`. The policy-only step records that without pausing
the run -- deliberately, since exit 11 would leave the poller spinning -- so the
run comes back COMPLETED and only the counts carry the intent.

Folding it into `warned` was wrong. `warned` maps to a `neutral` check, which
SATISFIES a required status check, so a policy demanding human sign-off silently
did not block. Ranked above `warned` it produces the `approval-required` verdict,
which the action maps to `action_required` -- honouring the author's intent
without implementing the approval workflow, which is out of scope here.

Caught by a live run against a real APPROVAL_REQUIRED policy: the rule reported
correctly and the verdict said `warned`, so the code handling
`approval-required` was unreachable from this path.
@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

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.

2 participants