Skip to content
Draft
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions docs/platform/ci-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion src/mcts/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[
Expand Down
16 changes: 16 additions & 0 deletions tests/test_category_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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