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
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).




## [1.2.0] - 2026-08-03

### Added
- `tirith platform check`: run 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.
- `ExitStatus.ERROR_POLICY_FAILED` (3), so a caller can tell "a policy said no" from "tirith could
not reach the platform". Exit 1 stays reserved for the latter, and applies even without
`--fail-on-error`: a run that produced no verdict must never look like a pass.

### Changed
- `cli.main(args=...)` is now honoured. It previously called `parse_args()` with no argument, so
the parameter was ignored and the CLI could only ever read `sys.argv`.

### Notes
- The local evaluation surface is unchanged, including its single-dash long options. Subcommands
are dispatched before the flat parser sees anything, so `--json` output stays byte-identical.
- No new runtime dependencies: the platform integration is stdlib-only.

## [1.1.0] - 2026-08-01

### Added
- `core`: Policy metadata passthrough — `meta.id`, `meta.name`, `meta.description`,
`meta.severity`, `meta.enforcement`, `meta.tags` and `meta.remediation` now reach the result
document when a policy declares them. Keys that are absent are omitted, so the output of a
policy declaring none of them is unchanged. `{{ var.x }}` substitution works in all of them.

### Fixed
- `core`: Variable substitution no longer mutates the caller's policy dictionary. Evaluating the
same parsed policy more than once (a policy set, or a retry) previously leaked substituted
values from one evaluation into the next.
- `core`: An unsupported `condition.type` now populates `result` instead of returning without it,
which raised `KeyError` in the pretty printer far from the real cause.
- `core`: Provider errors reported without a `ProviderError` severity are now surfaced instead of
being discarded and `None` evaluated against the condition — a typo'd `operation_type` read as
a genuine policy violation. These are treated as malformed provider calls and are deliberately
not subject to `error_tolerance`.

## [1.0.5] - 2025-11-19

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

setup(
name="py-tirith",
version="1.0.5",
version="1.2.0",
license="Apache",
description="Tirith simplifies defining Policy as Code.",
long_description_content_type="text/markdown",
Expand All @@ -48,7 +48,7 @@
"Operating System :: POSIX",
# 'Operating System :: Microsoft :: Windows',
"Programming Language :: Python",
# 'Programming Language :: Python :: 2.7',

Check warning on line 51 in setup.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_GkfNwWuIL-NWUCkCu&open=AZ_GkfNwWuIL-NWUCkCu&pullRequest=272
# 'Programming Language :: Python :: 3',
# 'Programming Language :: Python :: 3.5',
# 'Programming Language :: Python :: 3.6',
Expand Down
2 changes: 1 addition & 1 deletion src/tirith/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
tirith: Execute policies defined using Tirith (StackGuardian Policy Framework)
"""

__version__ = "1.0.5"
__version__ = "1.2.0"
__author__ = "StackGuardian"
__license__ = "Apache"
25 changes: 18 additions & 7 deletions src/tirith/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@

from .core import start_policy_evaluation


logger = logging.getLogger(__name__)


Expand All @@ -27,6 +26,13 @@
print(*args, file=sys.stderr, **kwargs)


# Subcommands are dispatched before the flat parser sees anything. argparse cannot express an
# optional subcommand alongside options like `-policy-path` (a single dash and a long name), and the
# local-evaluation surface is a contract: tests/core/test_output_compatibility.py asserts its --json
# output is byte-identical to a golden file. An explicit pre-dispatch leaves that untouched.
SUBCOMMANDS = {"platform"}


def main(args=None) -> ExitStatus:
"""
The main function.
Expand All @@ -36,6 +42,13 @@

Return exit status code.
"""
argv = list(sys.argv[1:] if args is None else args)

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

return platform_cli.main(argv)

try:

class _WidthFormatter(argparse.RawTextHelpFormatter):
Expand All @@ -45,8 +58,7 @@
parser = argparse.ArgumentParser(
description="Tirith (StackGuardian Policy Framework)",
formatter_class=_WidthFormatter,
epilog=textwrap.dedent(
"""\
epilog=textwrap.dedent("""\
About Tirith:

* Abstract away the implementation complexity of policy engine underneath.
Expand All @@ -55,8 +67,7 @@
* Provide modularity to enable easy extensibility
* Github - https://github.com/StackGuardian/tirith
* Docs - https://docs.stackguardian.io/docs/tirith/overview
"""
),
"""),
)
parser.add_argument(
"-policy-path",
Expand Down Expand Up @@ -104,9 +115,9 @@
)
parser.add_argument("--version", action="version", version=__version__)

args = parser.parse_args()
args = parser.parse_args(argv)

if len(sys.argv) == 1:
if not argv:
parser.print_help()
sys.exit(0)

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

# inputType = args.inputType

Check warning on line 165 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_GkfKmWuIL-NWUCkCr&open=AZ_GkfKmWuIL-NWUCkCr&pullRequest=272

except KeyboardInterrupt:
eprint("\nFailed because of Keyboard Interrupt")
Expand Down
26 changes: 24 additions & 2 deletions src/tirith/core/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from .evaluators import EVALUATORS_DICT
from .policy_parameterization import get_policy_with_vars_replaced


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -50,6 +49,10 @@
evaluator_class = EVALUATORS_DICT.get(evaluator_name)
if evaluator_class is None:
logger.error(f"{evaluator_name} is not a supported evaluator")
# Always populate "result" before returning. Consumers (the pretty printer, the
# workflow-step templates, the platform) index into it unconditionally, and an
# early return without it used to raise KeyError far away from the real cause.
result["result"] = [{"passed": False, "message": f"`{evaluator_name}` is not a supported evaluator"}]
return result

evaluator_instance = evaluator_class()
Expand All @@ -66,17 +69,28 @@
has_valid_evaluation = False

for evaluator_input in evaluator_inputs:
# A provider reported an error without attaching a ProviderError severity. That means a
# malformed provider call -- an unsupported operation_type, a missing required argument --
# not a policy violation. Surface the message and fail hard: error_tolerance exists to
# tolerate missing data, never to mask a broken policy. Without this branch the error text
# is discarded and `None` is evaluated against the condition, so a typo'd operation_type
# reads as a genuine violation.
if evaluator_input.get("err") and not isinstance(evaluator_input["value"], ProviderError):
evaluation_results.append({"passed": False, "message": evaluator_input["err"]})
has_evaluation_passed = False
continue

if isinstance(evaluator_input["value"], ProviderError) and evaluator_input.get("err", None):
severity_value = evaluator_input["value"].severity_value
err_result = dict(message=evaluator_input["err"])

Check warning on line 85 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

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

if severity_value > evaluator_error_tolerance:
err_result.update(dict(passed=False))

Check warning on line 88 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AZ_GkfDhWuIL-NWUCkCa&open=AZ_GkfDhWuIL-NWUCkCa&pullRequest=272
evaluation_results.append(err_result)
has_evaluation_passed = False
continue
# Mark as skipped evaluation
err_result.update(dict(passed=None))

Check warning on line 93 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AZ_GkfDhWuIL-NWUCkCb&open=AZ_GkfDhWuIL-NWUCkCb&pullRequest=272
evaluation_results.append(err_result)
has_evaluation_passed = None
continue
Expand Down Expand Up @@ -188,7 +202,7 @@
for key in eval_id_values:
regex_string = "\\b" + key + "\\b"
eval_string = re.sub(regex_string, str(eval_id_values[key]), eval_string)
# eval_string = eval_string.replace(key, str(eval_id_values[key]["passed"]))

Check warning on line 205 in src/tirith/core/core.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_GkfDhWuIL-NWUCkCc&open=AZ_GkfDhWuIL-NWUCkCc&pullRequest=272
# print (eval_string)

# TODO: shall we use and, or and not instead of symbols?
Expand Down Expand Up @@ -234,7 +248,7 @@
# TODO: validate policy_data against schema

with open(input_path) as f:
if input_path.endswith(".yaml") or input_path.endswith(".yml"):

Check warning on line 251 in src/tirith/core/core.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace chained "endswith" calls with a single call using a tuple argument.

See more on https://sonarcloud.io/project/issues?id=StackGuardian_policy-framework&issues=AZ_GkfDhWuIL-NWUCkCd&open=AZ_GkfDhWuIL-NWUCkCd&pullRequest=272
input_data = list(yaml.safe_load_all(f))
if len(input_data) == 1:
input_data = input_data[0]
Expand Down Expand Up @@ -302,8 +316,16 @@
eval_results.append(eval_result)
final_evaluation_result, errors = final_evaluator(final_evaluation_policy_string, eval_results_obj)

# Pass policy-declared metadata through to the result, but only the keys that are actually
# present. Absent keys are omitted rather than emitted as null, so the output of a policy
# that declares none of them is byte-identical to what it was before this was added.
final_output_meta = {"version": policy_meta.get("version"), "required_provider": provider_module}
for meta_key in ("id", "name", "description", "severity", "enforcement", "tags", "remediation"):
if meta_key in policy_meta:
final_output_meta[meta_key] = policy_meta[meta_key]

final_output = {
"meta": {"version": policy_meta.get("version"), "required_provider": provider_module},
"meta": final_output_meta,
"final_result": final_evaluation_result,
"evaluators": eval_results,
"errors": errors,
Expand Down
9 changes: 8 additions & 1 deletion src/tirith/core/policy_parameterization.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import copy
import re
import pydash

Expand Down Expand Up @@ -52,11 +53,17 @@ def get_policy_with_vars_replaced(policy_dict: dict, var_dict: dict) -> Tuple[di
"""
Replace the variables in the policy_dict with the values from the var_dict

The caller's `policy_dict` is never mutated: substitution happens on a deep copy. This
matters when the same parsed policy is evaluated more than once (for example a policy set
run against several inputs, or a retry), where substituted values would otherwise leak
from one evaluation into the next.

:param policy_dict: The policy dictionary
:param var_dict: The dictionary containing the variables
:return: The policy dictionary with the variables replaced
:return: A copy of the policy dictionary with the variables replaced
and the list of variables that are not found
"""
policy_dict = copy.deepcopy(policy_dict)
not_found_vars = []
# Replace vars in the meta key
_replace_vars_in_dict(policy_dict["meta"], var_dict, not_found_vars)
Expand Down
6 changes: 6 additions & 0 deletions src/tirith/platform/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
StackGuardian platform integration.

Everything here is stdlib-only on purpose: tirith has three runtime dependencies and none of them
are an HTTP library, so a CI runner needs nothing installed beyond tirith itself.
"""
Loading
Loading