1
0
Fork 0

feat(sanitize,fence,neutralize): reject oversize input instead of half-transforming it

The scanners cap by truncating: they return findings, so reading a prefix costs
detection in the tail and nothing else. The three transform surfaces return
*content*, where the same move is not available — a shortened document is silent
data loss, and a transformed prefix followed by an untransformed tail is a
bypass, since the attacker chooses where in the document the payload sits.

So they fail secure instead. Above MAX_INPUT_CHARS (1 000 000) sanitize, fence
and neutralize raise OversizeInputError. sanitize is step 1 of prepare_input and
only ever removes, so that one refusal bounds the whole input path.

OversizeInputError subclasses ContractViolation: a pipeline already bracketing
its quarantined stage keeps failing closed rather than meeting a type it has
never heard of. It inherits the alert-routable property too — sizes in the
message, refusing surface in details, no input in either.

Invariant now pinned across all three: returned text is always fully
transformed, or not returned at all.

Still uncapped and recorded in LIMITATIONS: scan_active_content called directly
(through scan_output it inherits that cap) and the okf link graph. Both are
detection-shaped, so truncate-and-flag transfers unchanged — mechanical, not
policy.

699 tests (+23), coverage 128/128 + 6/6, ReDoS sweep 0 candidates / 150.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-02 21:13:08 +02:00
commit 2d98d6809d
10 changed files with 272 additions and 21 deletions

View file

@ -48,9 +48,11 @@ from .disposition import (
from .contract import (
assert_tool_less,
assert_credential_allowlist,
assert_within_input_cap,
credential_env_names,
scoped_env,
ContractViolation,
OversizeInputError,
)
from .grounding import (
SourceGroundingCheck,
@ -140,6 +142,7 @@ __all__ = [
# contract asserters
"assert_tool_less", "assert_credential_allowlist",
"credential_env_names", "scoped_env", "ContractViolation",
"assert_within_input_cap", "OversizeInputError",
# grounding seam
"SourceGroundingCheck", "no_grounding_check", "DEFAULT_GROUNDING_CHECK",
# §6 bookends

View file

@ -47,6 +47,17 @@ ENTROPY_HEX_FLOOR_LEN = 64
# measured to do before the ReDoS fix (see active_content's pattern-table note).
MAX_SCAN_CHARS = 1_000_000
# --- transform surfaces: input cap ------------------------------------------
# The same size, and deliberately NOT the same constant, because the two caps
# buy different things and may need to move apart. MAX_SCAN_CHARS truncates: a
# scanner returns findings, so reading the prefix costs *detection* on the tail
# and nothing else. `sanitize` / `fence` / `neutralize` return content, so the
# equivalent move would hand back either a shortened document (silent data loss)
# or an untransformed tail (a bypass an attacker positions the payload into).
# They reject instead — see contract.OversizeInputError. Held at 1M so a
# document accepted by the input path is one the scanners can also read whole.
MAX_INPUT_CHARS = 1_000_000
# --- output: secret-egress self-safety --------------------------------------
# Longest password a connection-string pattern will match. A bound is required
# (not merely nice) because the run sits in front of a mandatory `@`: unbounded,

View file

@ -18,6 +18,12 @@ Three asserts, matching the reusable-contract checklist (BRIEF §6, steps 3-4):
env so a hijacked stage cannot even read a credential it was never granted;
the assert then passes by construction.
A fourth asserter lives here for the same reason it enforces rather than
reports though it belongs to the transform path rather than the quarantine
checklist: **(d) bounded transform input**, :func:`assert_within_input_cap`,
which the three content-returning surfaces call so an oversize document is
refused instead of half-transformed.
Reference: ``claude-code-llm-wiki`` ``tools/wiki_ingest/enrich.py``
``assert_quarantine`` a pipeline-specific quarantine gate, generalized here
into framework-agnostic, reusable pieces.
@ -51,6 +57,40 @@ class ContractViolation(Exception):
self.details = details
class OversizeInputError(ContractViolation):
"""A transform surface was handed more text than it will transform.
A *subclass*, not a sibling: a pipeline that already brackets its quarantined
stage in ``except ContractViolation`` keeps failing closed rather than
meeting an exception type it has never heard of. :attr:`details` names the
surface that refused; the sizes go in the message, and neither carries any
of the input, so the exception stays alert-routable like its parent.
"""
def assert_within_input_cap(text: str, *, surface: str, max_input_chars: int) -> None:
"""Raise :class:`OversizeInputError` unless ``text`` fits the transform cap.
The write-time asserter for the *transform* surfaces (d). Where the scanners
bound their work by truncating findings are lossy in the tail and nothing
else a transform returns content, so a prefix-only result is either silent
data loss or an untransformed tail the payload can be positioned into. The
invariant these three keep instead is: returned text is always fully
transformed, or not returned at all.
``max_input_chars`` is the largest accepted size, not the smallest rejected
one.
"""
size = len(text)
if size > max_input_chars:
raise OversizeInputError(
f"{surface}: input {size} chars exceeds cap {max_input_chars}; "
"refused rather than partially transformed",
code="oversize-input",
details=(surface,),
)
# --- (a) tool-less transform -----------------------------------------------
# Populated tool surface across Anthropic + OpenAI request shapes. An empty

View file

@ -24,6 +24,8 @@ import re
import secrets
from dataclasses import dataclass
from .calibration import MAX_INPUT_CHARS
from .contract import assert_within_input_cap
from .report import Finding, Report, Severity, Source
# Static delimiter skeleton. The per-call nonce is the security boundary; this
@ -59,9 +61,21 @@ def _redact(s: str, show_start: int = 16, show_end: int = 5) -> str:
return f"{s[:show_start]}...{s[-show_end:]}"
def fence(text: str, source: Source = Source.INPUT) -> FenceResult:
def fence(
text: str,
source: Source = Source.INPUT,
max_input_chars: int = MAX_INPUT_CHARS,
) -> FenceResult:
"""Strip fabricated fence markers from ``text``, then wrap it in a
randomized, unspoofable delimiter."""
randomized, unspoofable delimiter.
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
``max_input_chars``. Reached through :func:`prepare_input` the text is
already bounded (``sanitize`` only ever removes), so the guard matters for
direct callers and there it is load-bearing: a fence whose *body* was
truncated would present unstripped attacker markers as fenced data.
"""
assert_within_input_cap(text, surface="fence", max_input_chars=max_input_chars)
report = Report()
# 1. Strip attacker fence markers FIRST — otherwise a lucky guess of the

View file

@ -53,6 +53,8 @@ from .active_content import (
is_active_tag,
redact,
)
from .calibration import MAX_INPUT_CHARS
from .contract import assert_within_input_cap
from .report import Finding, Report, Severity, Source
@ -64,13 +66,23 @@ class NeutralizeResult:
report: Report
def neutralize(text: str, source: Source = Source.OUTPUT) -> NeutralizeResult:
def neutralize(
text: str,
source: Source = Source.OUTPUT,
max_input_chars: int = MAX_INPUT_CHARS,
) -> NeutralizeResult:
"""Defang active-content constructs in ``text`` and report each class.
Rewrites markdown images/links, reference-link definitions, angle-bracket
autolinks, raw active HTML, and ``data:`` URIs into inert forms. Text with no
such construct is returned byte-identical with an empty report.
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
``max_input_chars``. A partially defanged artifact is the worst outcome
available here: it *looks* neutralized, and the live constructs are all in
the tail nobody re-reads.
"""
assert_within_input_cap(text, surface="neutralize", max_input_chars=max_input_chars)
report = Report()
out = text

View file

@ -15,6 +15,8 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from .calibration import MAX_INPUT_CHARS
from .contract import assert_within_input_cap
from .report import Finding, Report, Severity, Source
# Invisible / steganographic character classes (codepoints).
@ -29,8 +31,10 @@ _TAG_LO, _TAG_HI = 0xE0000, 0xE007F # Unicode Tags block (U+E0000U+E007F)
# was wrong in the same way `output`'s was: a lazy run in front of a REQUIRED
# literal costs a full tail rescan at *every* start position when the literal
# never arrives, so `<!--` repeated to 100_000 chars measured 20.1s (exponent
# 1.962.14 over four doublings) — and this module, unlike `scan_lexicon` /
# `scan_output`, applies no input cap, so nothing bounds that above.
# 1.962.14 over four doublings) — and at the time this module, unlike
# `scan_lexicon` / `scan_output`, applied no input cap, so nothing bounded that
# above. MAX_INPUT_CHARS now does, but as a second line only: the cap bounds a
# *future* quadratic pattern's damage, it does not make a quadratic one safe.
#
# Neither of the two fixes used elsewhere fits here. Excluding the opener (`<`)
# from the run would drop every comment containing markup — `<!-- <b>x</b> -->`
@ -94,8 +98,19 @@ def _decode_tags(codepoints: list[int]) -> str:
return "".join(out)
def sanitize(text: str, source: Source = Source.INPUT) -> SanitizeResult:
"""Strip carrier classes from ``text`` and report per-class counts."""
def sanitize(
text: str,
source: Source = Source.INPUT,
max_input_chars: int = MAX_INPUT_CHARS,
) -> SanitizeResult:
"""Strip carrier classes from ``text`` and report per-class counts.
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
``max_input_chars``: this is step 1 of the input path, so the refusal bounds
the whole path, and a *partially* sanitized document is worse than none
the unstripped tail is where a carrier would be placed.
"""
assert_within_input_cap(text, surface="sanitize", max_input_chars=max_input_chars)
report = Report()
# Character-class carriers: single pass, keep everything else verbatim.