diff --git a/CHANGELOG.md b/CHANGELOG.md index c81d031..47c0bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Clarify that `--ci` and category gates are independent CI gates, including inclusive category limits. + ## [0.1.4] - 2026-06-12 ### Security diff --git a/docs/platform/ci-integration.md b/docs/platform/ci-integration.md index be6ccf4..ebf400c 100644 --- a/docs/platform/ci-integration.md +++ b/docs/platform/ci-integration.md @@ -132,6 +132,26 @@ mcts scan ./repo/ \ Category semantics: [Scoring Specification](../reporting/scoring-spec.md). Category gates apply to **legacy** v1 tiles only. +### Gate semantics + +MCTS evaluates these CI gates independently. Passing one gate does not imply another gate will pass. + +| Gate type | Flag | Fails when | Score type | +|-----------|------|------------|------------| +| Critical count | `--fail-on-critical` | Any CRITICAL finding exists | Finding count | +| Min overall | `--min-score N` / `--ci` | Legacy `score.overall` is below `N` | Exponential 0-100 overall score | +| Max critical | `--max-critical N` | Critical finding count is greater than `N` | Finding count | +| Category budget | `--fail-on-category category:N` | Legacy category risk score is greater than or equal to `N` | Category risk points | + +Category gates and the overall score use different formulas. For example, an API service can pass +`--fail-on-category injection:15` because its injection category risk is below 15, then still fail +`--ci` because the preset also applies `--fail-on-critical` and `--min-score 70` to the overall +legacy score. + +Category limits are inclusive. A zero limit means "fail at zero or above", so `permissions:0` fails +even when the permissions category score is 0. For D8-style "fail on any permissions risk" policies, +use `--fail-on-category permissions:1`. + ### Scoring v2 gates Scans include `score_v2` by default (`scoring: both`). **Gates** on v2 fields are opt-in: diff --git a/src/mcts/cli/main.py b/src/mcts/cli/main.py index 8c92d7c..ef85684 100644 --- a/src/mcts/cli/main.py +++ b/src/mcts/cli/main.py @@ -553,7 +553,10 @@ def scan( bool, typer.Option( "--ci", - help="Apply CI gate preset (fail-on-critical, min-score 70) and print score breakdown on failure", + help=( + "Apply CI gate preset (fail-on-critical, min-score 70; " + "category gates are separate) and print score breakdown on failure" + ), ), ] = False, policy: Annotated[ diff --git a/tests/test_category_gates.py b/tests/test_category_gates.py index cc240e2..8feb2ad 100644 --- a/tests/test_category_gates.py +++ b/tests/test_category_gates.py @@ -2,9 +2,14 @@ from __future__ import annotations +from typer.testing import CliRunner + +from mcts.cli.main import app from mcts.report.data import category_gate_failures, parse_category_gates from mcts.reporting.models import Finding, Severity +runner = CliRunner() + def test_parse_category_gates_accepts_comma_and_repeatable() -> None: gates = parse_category_gates(["permissions:10", "injection:5,execution:3"]) @@ -71,3 +76,14 @@ def test_category_gate_failure_message_never_says_passed_alone() -> None: message = failures[0] assert "inclusive gate" in message assert ">=" in message + + +def test_scan_help_says_ci_category_gates_are_separate() -> None: + result = runner.invoke(app, ["scan", "--help"]) + + assert result.exit_code == 0, result.stdout + help_text = " ".join(result.stdout.split()) + assert "--ci" in help_text + ci_help = help_text.split("--ci", 1)[1].split("--policy", 1)[0] + assert "category gates are" in ci_help + assert "separate" in ci_help