1
0
Fork 0

feat(active-content,okf): bound the last two detection surfaces

`scan_active_content` called directly and `okf.link_graph` were the two surfaces
still reading attacker-supplied text with no cap — the first reached by an
adapter that wants the active-content classes alone, the second running a
`findall` over every body in a bundle. Both are detection-shaped, so they
truncate and flag rather than raise the way the transform surfaces do: what a
detector shortens is its own coverage, not the caller's content.

Truncation is only honest if it is visible, so neither goes quiet: the scanner
emits `active:oversize-input` (LLM10), and `link_graph` records
`(from_id, body_length)` in `LinkGraphResult.truncated` — the field that lets a
caller tell "no links past here" from "no links read past here".

Reached through `scan_output`, the text is already under that surface's cap and
`max_scan_chars` is now passed down, so the flag is raised once, there.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-10 14:48:54 +02:00
commit b90233481a
6 changed files with 111 additions and 22 deletions

View file

@ -26,6 +26,7 @@ import re
from dataclasses import dataclass
from enum import Enum
from .calibration import MAX_SCAN_CHARS
from .output import scan_output
from .report import Report, Source
from .disposition import Trust, Disposition, Policy, decide
@ -428,12 +429,15 @@ class LinkGraphResult:
signal of §7.2 (a link planted to a not-yet-written concept). ``rejected`` —
``(from_id, target, reason)`` for links refused outright (dangerous scheme or
bundle escape). ``resolved`` — ``(from_id, target_concept_id)`` for links to
concepts present in the bundle.
concepts present in the bundle. ``truncated`` — ``(from_id, body_length)`` for
bodies read only as far as the scan cap, so a caller can tell "no links past
here" apart from "no links *read* past here" (OWASP LLM10).
"""
dangling: tuple
rejected: tuple
resolved: tuple
truncated: tuple = ()
def extract_link_targets(body):
@ -475,14 +479,20 @@ def resolve_link(target, from_concept_id):
return normalized[: -len(".md")]
def link_graph(bundle):
def link_graph(bundle, max_scan_chars=MAX_SCAN_CHARS):
"""Resolve every cross-link in ``bundle`` against the concepts it contains.
``bundle`` maps concept path to document text (as :func:`import_bundle`). Only
the body is scanned for links. See :class:`LinkGraphResult` for the outcome.
Self-safety (OWASP LLM10): every body is attacker-supplied and each is walked
by a `findall`, so each body is capped at ``max_scan_chars`` and recorded in
``truncated``. It truncates rather than raising, the way the scanners do: the
graph reports on documents, it does not hand them back, so a shortened scan
costs edges — not the caller's content.
"""
present = {p[: -len(".md")] for p in bundle if p.endswith(".md")}
dangling, rejected, resolved = [], [], []
dangling, rejected, resolved, truncated = [], [], [], []
for path in sorted(bundle):
if not path.endswith(".md"):
@ -493,6 +503,10 @@ def link_graph(bundle):
except OKFFrontmatterError:
body = bundle[path] # unparseable frontmatter is T2's reject, not ours
if len(body) > max_scan_chars:
truncated.append((from_id, len(body)))
body = body[:max_scan_chars]
for target in extract_link_targets(body):
try:
concept_id = resolve_link(target, from_id)
@ -506,7 +520,9 @@ def link_graph(bundle):
else:
dangling.append((from_id, concept_id))
return LinkGraphResult(tuple(dangling), tuple(rejected), tuple(resolved))
return LinkGraphResult(
tuple(dangling), tuple(rejected), tuple(resolved), tuple(truncated)
)
def _normalize_bundle_path(path):