Skip to content
6 changes: 6 additions & 0 deletions docs/changelog.d/980-essay-report-title-trust-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Essay report title trust boundary

## Fixed

- Hardened score, validation-evidence, and facets-calibration essay HTML renderers so caller-supplied titles admit only exact built-in strings, rejecting caller-controlled `str` subclasses before overridden text callbacks such as `strip()` or HTML-escaping operations can execute.
- Added hostile-string-subclass regressions that prove all three public renderers reject before callback execution or artifact creation; scoring, calibration estimation, and psychometric arithmetic remain unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ def render_essay_facets_calibration_report_html(
requested_output = Path(output_path)
if requested_output.suffix.lower() != ".html":
raise ValueError("essay facets calibration output path must end with .html")
if title is not None and (not isinstance(title, str) or not title.strip()):
if title is not None and (type(title) is not str or not title.strip()):
raise ValueError("essay facets calibration title must be a non-empty string")
output, approved_root = _bounded_output_path(requested_output, output_root)
resolved_title = _DEFAULT_TITLE if title is None else title
Expand Down
2 changes: 1 addition & 1 deletion python/fast_mlsirm/scoring/essay/report_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ def render_essay_score_report_html(
output = Path(output_path)
if output.suffix.lower() != ".html":
raise ValueError("essay score report output path must end with .html")
if title is not None and (not isinstance(title, str) or not title.strip()):
if title is not None and (type(title) is not str or not title.strip()):
raise ValueError("essay score report title must be a non-empty string")
resolved_title = _DEFAULT_TITLE if title is None else title
output.parent.mkdir(parents=True, exist_ok=True)
Expand Down
2 changes: 1 addition & 1 deletion python/fast_mlsirm/scoring/essay/validation_report_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ def render_essay_validation_evidence_report_html(
output = Path(output_path)
if output.suffix.lower() != ".html":
raise ValueError("essay validation evidence output path must end with .html")
if title is not None and (not isinstance(title, str) or not title.strip()):
if title is not None and (type(title) is not str or not title.strip()):
raise ValueError("essay validation evidence title must be a non-empty string")
resolved_title = _DEFAULT_TITLE if title is None else title
output.parent.mkdir(parents=True, exist_ok=True)
Expand Down
79 changes: 79 additions & 0 deletions tests/test_essay_report_title_trust_boundary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Regression tests for essay HTML report title trust boundaries."""

from __future__ import annotations

import runpy
from collections.abc import Callable
from pathlib import Path

import pytest

from fast_mlsirm.scoring.essay import (
render_essay_facets_calibration_report_html,
render_essay_score_report_html,
render_essay_validation_evidence_report_html,
)

_SCORE_FIXTURES = runpy.run_path(
str(Path(__file__).with_name("test_scoring_essay_report_html.py"))
)
_VALIDATION_FIXTURES = runpy.run_path(
str(Path(__file__).with_name("test_scoring_essay_validation_reporting.py"))
)
_FACETS_FIXTURES = runpy.run_path(
str(Path(__file__).with_name("test_scoring_essay_facets_reporting.py"))
)
clean_score_report = _SCORE_FIXTURES["clean_report"]
build_validation_report = _VALIDATION_FIXTURES["build_report"]
build_facets_report = _FACETS_FIXTURES["build_report"]


class _HostileTitle(str):
"""String subclass whose text callbacks must never cross a report boundary."""

def strip(self, *args: object, **kwargs: object) -> str:
"""Fail if validation invokes caller-controlled string behavior."""
raise AssertionError("hostile title strip callback executed")

def replace(self, *args: object, **kwargs: object) -> str:
"""Fail if HTML escaping invokes caller-controlled string behavior."""
raise AssertionError("hostile title replace callback executed")


@pytest.mark.parametrize(
("renderer", "report_factory", "message"),
(
(
render_essay_score_report_html,
clean_score_report,
"essay score report title must be a non-empty string",
),
(
render_essay_validation_evidence_report_html,
build_validation_report,
"essay validation evidence title must be a non-empty string",
),
(
render_essay_facets_calibration_report_html,
build_facets_report,
"essay facets calibration title must be a non-empty string",
),
),
)
def test_renderers_reject_string_subclasses_without_callbacks(
tmp_path: Path,
renderer: Callable[..., Path],
report_factory: Callable[[], object],
message: str,
) -> None:
"""All essay HTML titles reject subclasses before caller callbacks or writes."""
output = tmp_path / f"{report_factory.__name__}.html"

with pytest.raises(ValueError, match=message):
renderer(
report_factory(),
output,
title=_HostileTitle("audit title"),
)

assert not output.exists()
Loading