Skip to content
Closed
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
9 changes: 9 additions & 0 deletions docs/changelog.d/912-judge-category-control-safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Judge category-count control hardening

## Security

- Validate the public `validate_judge(..., k=...)` category count before compiled-core discovery.
- Accept exact built-in integers and genuine concrete NumPy integer scalars while rejecting booleans, subclasses, and arbitrary integer-conversion protocol providers without executing caller conversion callbacks.
- Marshal only a trusted built-in integer into the existing Rust-owned judge-validation computation; psychometric/fairness formulas, thresholds, and result schemas are unchanged.

Closes #912.
39 changes: 39 additions & 0 deletions docs/doctoring/judge_category_control_safety.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Judge category-count control safety

## Scope

Issue #912 hardens only the Python validation/marshalling boundary for the public `validate_judge(..., k=...)` category-count control. The Rust-owned agreement, quadratic-weighted kappa, degradation, standardized-mean-difference/fairness, gate-decision arithmetic, governed thresholds, and result schema are unchanged.

Protected `main` previously imported the compiled core before validating `k` and repeatedly invoked `int(k)`. Because Python integer subclasses and arbitrary integer-protocol objects may supply executable conversion methods, that order allowed caller-controlled conversion code to run while establishing a security- and fairness-relevant control.

The bounded correction admits only an exact built-in `int` or an exact concrete NumPy integer scalar identity, normalizes a trusted NumPy scalar once, enforces the existing `2..=1000` domain, completes label/policy marshalling, and only then imports the Rust core. Booleans, Python/NumPy subclasses, and arbitrary conversion-protocol providers fail before conversion callbacks or Rust dispatch. NumPy scalar-type admission uses identity comparisons rather than set membership so a caller-controlled scalar metaclass cannot inject `__hash__` or `__eq__` execution into the trust decision.

## Verification contract

The regression suite must prove all of the following on the exact PR head:

- hostile Python integer subclasses execute zero `__int__` callbacks;
- hostile NumPy integer subclasses execute zero `__int__` callbacks;
- arbitrary integer-protocol providers execute zero callbacks;
- caller-controlled NumPy scalar metaclasses execute zero hashing/equality callbacks during type admission;
- invalid exact category counts fail before compiled-core import;
- genuine concrete NumPy integer scalars remain compatible and arrive at the Rust boundary as an exact built-in integer; and
- no judge-validation numerical formula or policy threshold changes.

The initial test commit `7cf9eb6c2937020b5e755b5ae0a6cc2380fc068d` records the conversion/native-discovery RED contract, and `db0cb5848d317d39f197933043e289e00cdf522b` supplies its first bounded GREEN. A second RED at `3979f064b3e32fe44892cab369f8ffc1e3af4d73` demonstrates that hashed type-container membership would still execute caller-controlled metaclass hooks; `54fe33b2dd9d2a287c04635f2acba7bfc94f10fa` replaces that admission with identity-only comparisons. Hosted exact-head evidence remains authoritative over these remembered identities and must be refetched before lifecycle or integration decisions.

## Standards and research basis

The security references below govern input-validation and development-process evidence; they are not psychometric validity authorities. OWASP ASVS 5.0.0 is the latest stable ASVS release as checked on 2026-08-16; OWASP separately labels its bleeding-edge build as preview-only. NIST SP 800-218 Rev. 1 / SSDF 1.2 remains an Initial Public Draft, so this work retains final SSDF 1.1 as the normative NIST baseline and records the draft only as a standards-watch item.
Comment thread
seonghobae marked this conversation as resolved.

### References (APA 7th)

MITRE. (2026). *CWE-1287: Improper validation of specified type of input* (CWE Version 4.20). Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/1287.html

National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-218

National Institute of Standards and Technology. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). U.S. Department of Commerce. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd

OWASP Foundation. (2025). *OWASP application security verification standard 5.0.0*. https://github.com/OWASP/ASVS/tree/v5.0.0_release

Williamson, D. M., Xi, X., & Breyer, F. J. (2012). A framework for evaluation and use of automated scoring. *Educational Measurement: Issues and Practice, 31*(1), 2–13. https://doi.org/10.1111/j.1745-3992.2011.00223.x
66 changes: 53 additions & 13 deletions python/fast_mlsirm/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,48 @@
import numpy as np


MAX_JUDGE_CATEGORIES = 1_000
_TRUSTED_NUMPY_INTEGER_SCALAR_TYPES = tuple(
np.dtype(code).type
for code in ("b", "B", "h", "H", "i", "I", "l", "L", "q", "Q", "p", "P")
)


def _is_exact_numpy_integer_scalar_type(value_type: type) -> bool:
"""Return whether ``value_type`` is a package-trusted NumPy integer type.

Identity comparisons deliberately avoid hashing or equality on a
caller-controlled metaclass. This keeps type admission inert even for a
NumPy scalar subclass that overrides metaclass ``__hash__`` or ``__eq__``.
"""
return any(value_type is trusted_type for trusted_type in _TRUSTED_NUMPY_INTEGER_SCALAR_TYPES)


def _trusted_judge_category_count(value: object) -> int:
"""Return a trusted built-in category count without caller coercion callbacks.

Only an exact built-in :class:`int` or an exact concrete NumPy integer scalar
identity is normalized. Python/NumPy subclasses, booleans, and arbitrary
integer-protocol providers are rejected before ``int`` can execute caller
code. The returned value is always an exact built-in integer suitable for
downstream NumPy validation and PyO3 marshalling.
"""
value_type = type(value)
if value_type is int:
normalized = value
elif _is_exact_numpy_integer_scalar_type(value_type):
normalized = int(value)
else:
raise ValueError("k (number of categories) must be an integer")

if normalized < 2:
raise ValueError("k (number of categories) must be >= 2")
if normalized > MAX_JUDGE_CATEGORIES:
# k drives a dense k-by-k confusion matrix in the Rust core.
raise ValueError(f"k (number of categories) must be <= {MAX_JUDGE_CATEGORIES}")
return normalized


def _validate_labels(a, name: str, *, k: int | None = None, n: int | None = None) -> np.ndarray:
"""Validate caller-supplied category labels before the uint32 conversion the
Rust gate expects: reject non-1-D, wrong-length, non-finite, non-integer,
Expand Down Expand Up @@ -130,27 +172,20 @@ def validate_judge(
degradation criterion; ``subgroup`` labels each observation for the
fairness SMD.
"""
from . import _core # computation lives in the Rust core

active_policy = policy if policy is not None else ValidationPolicy()
if not isinstance(active_policy, ValidationPolicy):
raise TypeError("policy must be a ValidationPolicy")

MAX_JUDGE_CATEGORIES = 1_000
if int(k) < 2:
raise ValueError("k (number of categories) must be >= 2")
if int(k) > MAX_JUDGE_CATEGORIES:
# k drives a dense k-by-k confusion matrix in the Rust core.
raise ValueError(f"k (number of categories) must be <= {MAX_JUDGE_CATEGORIES}")
judge_v = _validate_labels(judge, "judge", k=int(k))
human_v = _validate_labels(human, "human", k=int(k), n=judge_v.shape[0])
category_count = _trusted_judge_category_count(k)
judge_v = _validate_labels(judge, "judge", k=category_count)
human_v = _validate_labels(human, "human", k=category_count, n=judge_v.shape[0])
kwargs: dict[str, Any] = {}
if human_human is not None:
kwargs["human_a"] = _validate_labels(
human_human[0], "human_a", k=int(k), n=judge_v.shape[0]
human_human[0], "human_a", k=category_count, n=judge_v.shape[0]
)
kwargs["human_b"] = _validate_labels(
human_human[1], "human_b", k=int(k), n=kwargs["human_a"].shape[0]
human_human[1], "human_b", k=category_count, n=kwargs["human_a"].shape[0]
)
if subgroup is not None:
sg = _validate_labels(subgroup, "subgroup", n=judge_v.shape[0])
Expand All @@ -159,10 +194,13 @@ def validate_judge(
_uniq, sg_compact = np.unique(sg, return_inverse=True)
kwargs["subgroup"] = sg_compact.astype(np.uint32)
kwargs.update(active_policy.rust_kwargs())

from . import _core # computation lives in the Rust core

res = _core.validate_scoring(
judge_v,
human_v,
int(k),
category_count,
**kwargs,
)
gates = [dict(g) for g in res["gates"]]
Expand Down Expand Up @@ -267,6 +305,8 @@ def fleiss_kappa(
category_z=np.asarray(res["category_z"]),
category_p=np.asarray(res["category_p"]),
)


@dataclass
class LightKappaResult:
"""Result of :func:`light_kappa`: mean pairwise unweighted Cohen's kappa
Expand Down
156 changes: 156 additions & 0 deletions tests/test_validation_category_control_safety.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Security regressions for judge category-count validation."""

from __future__ import annotations

import builtins
from types import SimpleNamespace

import numpy as np
import pytest

import fast_mlsirm
from fast_mlsirm import validation


class _HostileInt(int):
"""Integer subclass whose conversion callback records execution."""

calls = 0

def __int__(self) -> int:
type(self).calls += 1
return 2


class _HostileNumpyInt(np.int64):
"""NumPy integer subclass whose conversion callback records execution."""

calls = 0

def __int__(self) -> int:
type(self).calls += 1
return 2


class _HostileScalarMeta(type):
"""Metaclass proving scalar-type admission cannot hash caller types."""

calls = 0

def __hash__(cls) -> int:
type(cls).calls += 1
return type.__hash__(np.int64)

def __eq__(cls, other: object) -> bool:
type(cls).calls += 1
return type.__eq__(cls, other)


class _MetaclassHostileNumpyInt(np.int64, metaclass=_HostileScalarMeta):
"""NumPy scalar subclass with caller-controlled type hash/equality hooks."""


class _IntegerProtocolProvider:
"""Arbitrary integer protocol provider that must never be invoked."""

calls = 0

def __int__(self) -> int:
type(self).calls += 1
return 2


def _fake_core(calls: list[tuple[int, type[int]]]) -> SimpleNamespace:
"""Return a deterministic Rust-boundary stand-in for marshalling tests."""

def validate_scoring(judge, human, k, **kwargs):
del judge, human, kwargs
calls.append((k, type(k)))
return {
"gates": [],
"exact_agreement": 1.0,
"adjacent_agreement": 1.0,
"pass": True,
}

return SimpleNamespace(validate_scoring=validate_scoring)


@pytest.mark.parametrize(
"control_type",
[_HostileInt, _HostileNumpyInt, _IntegerProtocolProvider],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important / should-fix (not a production hole): this parametrize does not match #912 items 2 and 5 or the repo integer-safety RED contract in tests/test_scoring_execution_integer_callback_safety.py.

Missing cases that production already rejects with zero callbacks (probed on this head): True / False / np.bool_; an __index__-only provider; a 0-d np.ndarray (int(np.array(2)) still works in Python); and repr/eq/hash/lt/gt hooks on the hostile objects. _IntegerProtocolProvider only implements __int__.

Hostile tests also only assert rust_calls == []. They stay green if from . import _core moves back above the type check. Extend test_validate_judge_rejects_invalid_k_before_core_import to type-invalid k, and patch sys.modules['fast_mlsirm._core'] as well as fast_mlsirm._core so the NumPy dispatch assertion remains meaningful after other tests have imported the extension.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parametrize covers __int__ subclasses and one protocol object, but #912 also names __index__-only providers, booleans/np.bool_, and 0-d arrays. The import-guard test below only uses in-type k=1, so a later from . import _core move would still leave these hostile cases green.

#935 adds those RED cases and pins the fake through sys.modules['fast_mlsirm._core']. Take that head rather than expanding this file in place if #935 is still open.

)
def test_validate_judge_rejects_executable_integer_controls_without_callbacks(
monkeypatch: pytest.MonkeyPatch,
control_type: type,
) -> None:
"""Rejected category controls cannot run conversion code or Rust dispatch."""
control_type.calls = 0
rust_calls: list[tuple[int, type[int]]] = []
monkeypatch.setattr(fast_mlsirm, "_core", _fake_core(rust_calls), raising=False)

value = control_type(2) if control_type is not _IntegerProtocolProvider else control_type()
with pytest.raises(ValueError):
validation.validate_judge(np.array([0, 1]), np.array([0, 1]), k=value)

assert control_type.calls == 0
assert rust_calls == []


def test_validate_judge_rejects_hostile_scalar_metaclass_without_callbacks(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Scalar-type admission cannot dispatch caller metaclass hash/equality hooks."""
_HostileScalarMeta.calls = 0
rust_calls: list[tuple[int, type[int]]] = []
monkeypatch.setattr(fast_mlsirm, "_core", _fake_core(rust_calls), raising=False)

with pytest.raises(ValueError):
validation.validate_judge(
np.array([0, 1]),
np.array([0, 1]),
k=_MetaclassHostileNumpyInt(2),
)

assert _HostileScalarMeta.calls == 0
assert rust_calls == []


def test_validate_judge_rejects_invalid_k_before_core_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An invalid exact category count fails before compiled-core discovery."""
real_import = builtins.__import__
core_import_calls = 0

def guarded_import(name, globals_=None, locals_=None, fromlist=(), level=0):
nonlocal core_import_calls
if "_core" in fromlist:
core_import_calls += 1
raise AssertionError("compiled core discovered before k validation")
return real_import(name, globals_, locals_, fromlist, level)

monkeypatch.setattr(builtins, "__import__", guarded_import)

with pytest.raises(ValueError, match="must be >= 2"):
validation.validate_judge(np.array([0, 1]), np.array([0, 1]), k=1)

assert core_import_calls == 0


@pytest.mark.parametrize(
"k",
[np.int8(2), np.uint16(2), np.int32(2), np.uint64(2)],
)
def test_validate_judge_normalizes_genuine_numpy_category_counts(
monkeypatch: pytest.MonkeyPatch,
k: np.integer,
) -> None:
"""Supported concrete NumPy integer scalars marshal as exact Python ints."""
rust_calls: list[tuple[int, type[int]]] = []
monkeypatch.setattr(fast_mlsirm, "_core", _fake_core(rust_calls), raising=False)

verdict = validation.validate_judge(np.array([0, 1]), np.array([0, 1]), k=k)

assert verdict.passed is True
assert rust_calls == [(2, int)]
Loading