Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov

Check warning on line 17 in .github/workflows/coverage.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AZ__baCptUwP2ked6qDl&open=AZ__baCptUwP2ked6qDl&pullRequest=280

Check warning on line 17 in .github/workflows/coverage.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AZ__baCptUwP2ked6qDm&open=AZ__baCptUwP2ked6qDm&pullRequest=280
pip install .
# With the 'tui' extra, so the interface's own tests actually run here. Installed
# plain, they skip -- and every line behind them counts as uncovered, which is a
# coverage report describing the environment rather than the tests.
pip install '.[tui]'

Check warning on line 21 in .github/workflows/coverage.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Omitting "--only-binary :all:" can lead to the execution of setup scripts. Make sure it is safe here.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AZ__baCptUwP2ked6qDn&open=AZ__baCptUwP2ked6qDn&pullRequest=280

Check warning on line 21 in .github/workflows/coverage.yml

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Using dependencies without locking resolved versions is security-sensitive.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AZ__baCptUwP2ked6qDo&open=AZ__baCptUwP2ked6qDo&pullRequest=280
- name: Run tests with coverage
run: |
pytest --cov-branch --cov-report=xml --cov=tirith tests/
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0



## [Unreleased]

### Added
- `tirith ui`: an interactive interface with three tabs.
- **Explorer** — read an evaluation's results down to the resource behind each one. The result
document has always carried the resource address, the planned action and the before/after
values; the pretty printer prints only the message, so this detail was reachable only by
piping `--json` into another tool. Opens on the first failure, names replacements by their
ordering (destroy-first and create-first mean different things), and shows the attributes
that changed, flagging the ones that are unknown until apply.
- **Builder** — assemble a policy from a form whose fields follow the chosen provider and
operation. Values keep their JSON types, so `Equals: true` and `Equals: "true"` stay
distinguishable.
- **Playground** — edit a policy and an input side by side and watch the verdict move, with
five worked examples that mostly fail on purpose and explain why.
- `--serve` runs the same interface over HTTP for a browser.
- Optional extra: `pip install 'py-tirith[tui]'`. Not a hard dependency — the interface needs
Python 3.9 while tirith supports 3.8, and using tirith as a CI gate should stay
dependency-light. Without it, `tirith ui` prints how to install it and exits 1.
- A policy validator behind the interface, reporting the mistakes that are otherwise silent:
a provider argument the operation does not read, an id referenced in `eval_expression` but
never defined, a single `&` where `&&` was meant, an evaluator that does not exist.

### Notes
- The local evaluation surface is untouched. `ui` is dispatched before the flat parser, like
`platform`, so `--json` output remains byte-identical to the golden file.
- No new runtime dependencies for anyone who does not install the extra.

## [1.2.0] - 2026-08-03

### Added
Expand Down
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ recursive-include resources *.svg
recursive-include resources Makefile
recursive-include src *.json
recursive-include src *.md
# The TUI's stylesheet, and the about.md beside each bundled playground example. Without
# these the installed interface loads unstyled and the playground has nothing to open.
recursive-include src *.css
recursive-include src *.new
recursive-include src *.old
recursive-include src *.py
Expand Down
125 changes: 125 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ verdict, same exit codes. That mode is optional and is the only part that talks
- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)
- [The interactive interface](#the-interactive-interface)
- [Explorer](#explorer)
- [Builder](#builder)
- [Playground](#playground)
- [Serving it on a port](#serving-it-on-a-port)
- [Run it in CI](#run-it-in-ci)
- [Exit codes](#exit-codes)
- [Evaluating against your StackGuardian organization](#evaluating-against-your-stackguardian-organization)
Expand Down Expand Up @@ -186,6 +191,8 @@ Subcommands:

tirith platform check --help Evaluate against the policies your StackGuardian
organization enforces, rather than local files.
tirith ui --help Explore results, build policies and experiment in
an interactive interface. Needs the 'tui' extra.

About Tirith:

Expand All @@ -198,6 +205,124 @@ About Tirith:
```


## The interactive interface

`tirith ui` opens a terminal interface with three tabs: an **Explorer** for reading results, a
**Builder** for assembling policies, and a **Playground** for experimenting.

It is an optional extra, because tirith's main job is to be a dependency-light CI gate and
nobody gating a pipeline should pay to install an interface they never open. It needs Python
3.9 or newer, while tirith itself still supports 3.8:

```bash
pip install 'py-tirith[tui]'
```

```bash
tirith ui # playground, with worked examples
tirith ui --policy policy.json --input plan.json # evaluate yours, open on the results
tirith ui --result result.json # an evaluation you already ran
tirith --json -policy-path p.json -input-path plan.json | tirith ui --result -
```

Naming both a policy and an input evaluates them and opens the **Explorer**, because that is
what you came to see. With only a policy, or nothing at all, it opens the Playground.

### Explorer

The output of `--json` and the pretty printer both tell you *that* a check failed. Neither
tells you *which resource* failed it — although the result document has carried the resource's
address, its planned action and its before/after values all along.

The Explorer shows them. Selecting a failing result names the resource
(`aws_db_instance.primary`), the action in terraform's own vocabulary (**replace (destroy
first)** — distinct from create-first, because only one of them means downtime), and the
attributes that changed, including the ones that are unknown until apply.

This matters most on a wildcard policy, where every message reads identically
(`` `"product-456"` is not empty ``) and only the address distinguishes one row from another.

Three ways to get your own results in front of it:

```bash
# 1. Evaluate now. Opens on the Explorer with the first failure selected.
tirith ui --policy policy.json --input plan.json

# 2. A result you saved earlier -- a CI artifact, a colleague's run.
tirith --json -policy-path policy.json -input-path plan.json > result.json
tirith ui --result result.json

# 3. Straight off a pipe, without the intermediate file.
tirith --json -policy-path policy.json -input-path plan.json | tirith ui --result -
```

The pipe needs a terminal to run in, since it is an interactive interface: if stdin is a pipe
and there is no terminal behind it — a CI job with output redirected — it says so instead of
starting and immediately exiting. `--serve` cannot read stdin at all, because the served
interface is a separate process with its own; pass it a file path.

### Builder

Pick a provider, an operation and a condition; the form's fields change to whatever that
operation actually accepts, and the policy JSON is generated as you go. The provider argument
names are not guessable — `stackguardian/json` reads `key_path` while
`stackguardian/kubernetes` reads `attribute_path`, and the terraform provider alone has seven
operations taking different arguments — so the form exists to stop you writing a policy that
parses cleanly and silently matches nothing.

Values keep their JSON types: typing `true` gives you a boolean, `["a","b"]` a list, and
`production` the string, because `Equals: "true"` and `Equals: true` are different questions.

**How the checks combine** is its own field, holding the policy's `eval_expression`:

| | |
| --- | --- |
| `a && b` | both must pass |
| `a \|\| b` | either may pass |
| `!a` | passes when the check *fails* — how you write a detector |
| `(a \|\| b) && c` | grouping |

It fills itself in with every check `&&`-ed together, and stops doing that the moment you edit
it. The expression is the one part of a policy that cannot be derived from the checks, so
regenerating it after you have written `a && !b` would throw away the only thing you could not
have expressed any other way.

The form also names the document each provider expects, because choosing a provider is
choosing what you have to feed it.

### Playground

Load one of the bundled examples, change something, watch the verdict move. Evaluation runs as
you type. Broken JSON, a half-written policy and a provider that raises are all reported in the
findings pane rather than as a traceback — while you are editing, the broken state is the
normal state.

The examples are worked lessons rather than fixtures. Most of them fail on purpose, and each
one's notes explain the mechanism it demonstrates and what to try next:

| Example | Demonstrates |
| --- | --- |
| Required tags | One check, one condition, nested attributes. Why `error_tolerance` can turn a failure into a *skip* — and why a skip is not a pass. |
| No public buckets | Two checks joined with `&&`; two buckets, one at fault. |
| Cost ceiling | The infracost provider, and why a misspelled resource type sums to `0` and fails open. |
| Block destroy | A database being replaced inside a routine plan, and the attribute that forced it. |
| Kubernetes probes | Wildcard paths, why `IsNotEmpty` is the wrong question over a list, and the `!` operator. |

### Serving it on a port

The same interface runs in a browser, which is useful for sharing a result with someone who
does not have tirith installed:

```bash
tirith ui --serve --port 8000 # then open http://localhost:8000
```

It is the same interface relayed to the browser, not a second web-only implementation, so it
behaves identically and there is nothing extra to keep in sync.

Bind address and port are yours to choose, but note the served interface can read any file path
the serving process can. Keep it on `localhost` unless you have a reason not to.

## Run it in CI

### GitHub Actions
Expand Down
27 changes: 24 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ def read(*names, **kwargs):
package_dir={"": "src"},
py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")],
include_package_data=True,
# Declared explicitly as well as in MANIFEST.in: MANIFEST governs the sdist, but a wheel
# built straight from the tree takes its data files from here. Without this the TUI
# installs with no stylesheet and no examples -- it starts, and every playground pane is
# empty, which is a worse failure than not starting at all.
package_data={
"tirith.tui": ["*.css"],
"tirith.tui.examples": ["*/*.json", "*/*.md"],
},
zip_safe=False,
classifiers=[
# complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers
Expand Down Expand Up @@ -71,9 +79,22 @@ def read(*names, **kwargs):
python_requires=">=3.8",
install_requires=["simplejson==3.17.2", "pydash==6.0.0", "PyYAML==6.0.1"],
extras_require={
# eg:
# 'rst': ['docutils>=0.11'],
# ':python_version=="2.6"': ['argparse'],
# `pip install py-tirith[tui]` adds the interactive interface (`tirith ui`).
#
# An extra rather than a dependency, for two reasons. The UI toolkit requires Python
# >=3.9 while tirith supports >=3.8, so a hard dependency would drop 3.8 support for
# everyone; the environment markers below let 3.8 users install the extra and simply
# get nothing rather than an error. And tirith's main use is as a CI gate, where every
# dependency is install time on every run -- people gating a pipeline should not pay
# for an interface they never open.
# textual>=8.0 rather than a looser floor: `Select.NULL` (the unselected sentinel the
# Playground and Builder both test against) only exists from 8.0. Before that it was
# named Select.BLANK, so on 0.60-7.x the example picker raises AttributeError the first
# time the prompt row is chosen. `Select(compact=...)` is likewise newer than 0.60.
"tui": [
'textual>=8.0; python_version >= "3.9"',
'textual-serve>=1.0; python_version >= "3.9"',
],
},
setup_requires=[
"pytest-runner",
Expand Down
15 changes: 14 additions & 1 deletion src/tirith/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@
# `--fail-on-error` fixed. No alias in either direction: nothing is released, so there is no caller to
# keep working.
SUBCOMMAND = "platform"
SUBCOMMANDS = {SUBCOMMAND}

# `ui` joins it on the same terms: dispatched before the flat parser, so the local-evaluation
# surface and its golden-file output are untouched. It is an optional extra -- it needs Python
# 3.9 and tirith supports 3.8 -- so tui/cli.py reports the missing extra rather than failing on
# an import here.
UI_SUBCOMMAND = "ui"
SUBCOMMANDS = {SUBCOMMAND, UI_SUBCOMMAND}


def main(args=None) -> ExitStatus:
Expand All @@ -54,6 +60,11 @@
"""
argv = list(sys.argv[1:] if args is None else args)

if argv and argv[0] == UI_SUBCOMMAND:
from tirith.tui import cli as tui_cli

return tui_cli.main(argv)

if argv and argv[0] in SUBCOMMANDS:
from tirith.platform import cli as platform_cli

Expand All @@ -74,6 +85,8 @@

tirith platform check --help Evaluate against the policies your StackGuardian
organization enforces, rather than local files.
tirith ui --help Explore results, build policies and experiment in
an interactive interface. Needs the 'tui' extra.

About Tirith:

Expand Down Expand Up @@ -228,7 +241,7 @@
# print("'--input-type' argument is required")
# return ExitStatus.ERROR

# inputType = args.inputType

Check warning on line 244 in src/tirith/cli.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this commented out code.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AZ__baAdtUwP2ked6qDX&open=AZ__baAdtUwP2ked6qDX&pullRequest=280

except KeyboardInterrupt:
eprint("\nFailed because of Keyboard Interrupt")
Expand Down
31 changes: 31 additions & 0 deletions src/tirith/tui/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
Interactive interface for Tirith: explore results, build policies, experiment in a playground.

Optional. Everything that needs the UI toolkit lives in .app and its views, imported lazily by
run() rather than here, so that:

* `import tirith.tui.schema` works without the extra installed -- which is what lets the
schema drift-guard tests run on CI's Python 3.8 leg, where the toolkit cannot be installed
at all (it requires >=3.9, tirith supports >=3.8); and
* a user who installed plain `py-tirith` gets an actionable message instead of an
ImportError traceback.
"""

TUI_EXTRA_HINT = (
"The Tirith interactive interface needs the optional 'tui' extra:\n"
" pip install 'py-tirith[tui]'\n"
"It is optional so that using tirith as a CI gate stays dependency-light. It needs "
"Python 3.9 or newer; tirith itself supports 3.8."
)


def run(argv=None):
"""
Entry point for `tirith ui`. Imports the app lazily; see the module docstring.

:param argv: Arguments after the `ui` subcommand.
:return: An ExitStatus.
"""
from .cli import main

return main(argv or [])
Loading
Loading