feat(active-content): raw HTML graded on carrier, and a tag naming no target is inert
Two changes that had to ship together, because they co-occur. `active:raw-html-link` (MEDIUM) splits the click-required carriers out of `active:raw-html`. The same URL was LOW as `[t](url)` and HIGH as `<a href="url">` — an asymmetry produced by syntax, not by affordance, on a carrier the markdown path has graded MEDIUM since 0.3.1. The event-handler test runs first, so `<a onclick=...>` stays HIGH. The url-attribute branch stays HIGH too: a name outside the active set has unknown rendering, and grading `<Card src=...>` as a link would be reasoning rather than measurement. The no-URL narrowing makes `</a>`, `<Frame>`, `<video />` and `<img alt=...>` without `src` inert — `<base />`'s argument from 0.6.0 applied to the rest of the name branch. It tests for the URL attribute's PRESENCE, not for a readable value, so the fail-secure gap `_url_attr_is_external` leaves open is not reopened here. WHY TOGETHER: the narrowing strips a document's `</a>`/`<Frame>` and what remains is the `<a href=...>` the split grades down, so each alone leaves the document blocked by the other's residue. `active_tag_class` is now the classification point and `is_active_tag` wraps it. The census patches the former: a boolean could only express a narrowing, never a regrade, so every carrier candidate would have measured equal to PRODUCTION — silently, and in the direction that reads as "no change helps". TWO COSTS, BOTH RECORDED RATHER THAN GLOSSED: - The split TIGHTENS the trusted tier. One finding becomes two, and >=2 findings at MEDIUM+ trip the compound overlay, so a document carrying both an `<img src>` and an `<a href>` goes WARN -> quarantine_review on PRESET_TRUSTED_SOURCE. On that preset it is the only direction the split can move anything. The census now reports a TIGHTENS column on both trust tiers against the previously shipped row — "frees N" without "tightens M" is a one-sided number. - `count` drops on documents containing `</a>`, a published field moving under a meaning that did not change. MEASURED: reference-corpus (389) 54 -> 53 fail_secure, tightens 0/0, and the census `PRODUCTION` row equals its `C1 + D` candidate row for row. The census also reproduces 133/3/13/108/25 exactly, so it is calibrated against every published historical number. The two wiki corpora are NOT yet re-measured; the tree says so explicitly in the docstring, LIMITATIONS and CHANGELOG rather than carrying probe numbers as fact. 791 tests (was 759), coverage 129/129, 6/6 documented gaps holding. Version bumped to 0.7.0 across every surface; no tag is set until the measurement lands.
This commit is contained in:
parent
0df7e87c2f
commit
fcfaee4589
18 changed files with 544 additions and 99 deletions
|
|
@ -1,4 +1,4 @@
|
|||
"""raw-HTML census — which branch of `is_active_tag` fires, and what narrowing it costs.
|
||||
"""raw-HTML census — which branch of `active_tag_class` fires, and what a change costs.
|
||||
|
||||
`docs/fp-sweep.py` answers *how often* the upload door costs a human. This answers
|
||||
*why*, for the one detector that drives most of it, and *what a proposed narrowing
|
||||
|
|
@ -22,11 +22,17 @@ TWO METHOD TRAPS IT EXISTS TO AVOID:
|
|||
effect with corpus drift. Every candidate here runs against the same corpus state
|
||||
in one process, and `base` is re-measured rather than quoted from the doc.
|
||||
|
||||
The candidates are applied by replacing `active_content.is_active_tag` in-process,
|
||||
which mirrors a real edit to the *scanner*. Since 0.6.0 that is the whole story:
|
||||
`neutralize` calls its own `is_defangable_tag`, so patching this symbol cannot
|
||||
move the mutator. Before 0.6.0 the two shared one symbol and this caveat read the
|
||||
other way. See the raw-HTML bullets in `docs/LIMITATIONS.md`.
|
||||
The candidates are applied by replacing `active_content.active_tag_class`
|
||||
in-process, which mirrors a real edit to the *scanner*. Since 0.6.0 that is the
|
||||
whole story: `neutralize` calls its own `is_defangable_tag`, so patching this
|
||||
symbol cannot move the mutator. Before 0.6.0 the two shared one symbol and this
|
||||
caveat read the other way. See the raw-HTML bullets in `docs/LIMITATIONS.md`.
|
||||
|
||||
The patch point was `is_active_tag` through 0.6.1, when a candidate could only
|
||||
answer yes/no. 0.7.0 grades raw HTML on carrier as well, so a candidate returns a
|
||||
CLASS and `is_active_tag` became a thin wrapper. A boolean patch point would have
|
||||
left every regrade candidate equal to PRODUCTION — silently, and in the direction
|
||||
that reads as "no change helps".
|
||||
|
||||
The `PRODUCTION` row is the only one that is not a hypothetical: it leaves the
|
||||
shipped predicate in place. A shipped narrowing must equal its candidate row, and
|
||||
|
|
@ -47,11 +53,22 @@ from pathlib import Path
|
|||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
||||
|
||||
from llm_ingestion_guard import ( # noqa: E402
|
||||
PRESET_USER_UPLOAD, Disposition, screen_output,
|
||||
PRESET_TRUSTED_SOURCE, PRESET_USER_UPLOAD, Disposition, guard, scan_output,
|
||||
)
|
||||
from llm_ingestion_guard import active_content as ac # noqa: E402
|
||||
|
||||
BENIGN = Disposition.WARN
|
||||
BLOCKED = Disposition.FAIL_SECURE
|
||||
# Both trust tiers, because a change can move them in OPPOSITE directions: the
|
||||
# carrier split loosens the upload door (HIGH -> MEDIUM is fail_secure ->
|
||||
# quarantine_review) while tightening the trusted one (one finding becomes two,
|
||||
# and >=2 findings at MEDIUM+ trip the compound overlay). A single-preset census
|
||||
# would have reported only the half that flattered the change.
|
||||
PRESETS = {"upload": PRESET_USER_UPLOAD, "trusted": PRESET_TRUSTED_SOURCE}
|
||||
# Disposition severity order, for "did this document get strictly worse?".
|
||||
_TIER = {Disposition.WARN: 0, Disposition.QUARANTINE_REVIEW: 1, Disposition.FAIL_SECURE: 2}
|
||||
# The row a release is judged against: what consumers are running today.
|
||||
_SHIPPED_BEFORE_ROW = "A + base-url (0.6.0)"
|
||||
|
||||
def has_external_url_attr(attrs: str) -> bool:
|
||||
"""True if any URL-bearing attribute points at an attacker-reachable target.
|
||||
|
|
@ -68,20 +85,41 @@ def has_external_url_attr(attrs: str) -> bool:
|
|||
return ac._url_attr_is_external(attrs)
|
||||
|
||||
|
||||
def _variant(*, drop: frozenset[str] = frozenset(), external_only: bool = False):
|
||||
"""Build an `is_active_tag` replacement: names minus ``drop``, URL branch gated."""
|
||||
def _variant(*, drop: frozenset[str] = frozenset(), external_only: bool = False,
|
||||
carrier_split: bool = False, no_url: bool = False):
|
||||
"""Build an `active_tag_class` replacement.
|
||||
|
||||
Candidates return the tag's CLASS (``"raw-html"`` / ``"raw-html-link"``) or
|
||||
``None``, because since 0.7.0 the raw-HTML pass grades on carrier as well as
|
||||
activity, and a boolean could not express a regrade. ``drop`` removes names
|
||||
from the active set, ``external_only`` gates the URL branch, ``carrier_split``
|
||||
moves the click-required carriers to the link class, and ``no_url`` makes a
|
||||
URL-affordance tag carrying no URL attribute inert.
|
||||
"""
|
||||
keep = frozenset(ac._ACTIVE_TAGS - drop)
|
||||
|
||||
def is_active_tag(name: str, attrs: str) -> bool:
|
||||
if name.lower() in keep or ac._EVENT_ATTR_RE.search(attrs):
|
||||
return True
|
||||
if not ac._URL_ATTR_RE.search(attrs):
|
||||
return False
|
||||
return has_external_url_attr(attrs) if external_only else True
|
||||
def active_tag_class(name: str, attrs: str):
|
||||
lowered = name.lower()
|
||||
if ac._EVENT_ATTR_RE.search(attrs):
|
||||
return "raw-html"
|
||||
has_url_attr = bool(ac._URL_ATTR_RE.search(attrs))
|
||||
if lowered in keep:
|
||||
if no_url and lowered in ac._URL_AFFORDANCE_TAGS and not has_url_attr:
|
||||
return None
|
||||
if carrier_split and lowered in ac._LINK_TAGS:
|
||||
return "raw-html-link"
|
||||
return "raw-html"
|
||||
if not has_url_attr:
|
||||
return None
|
||||
if external_only and not has_external_url_attr(attrs):
|
||||
return None
|
||||
return "raw-html"
|
||||
|
||||
return is_active_tag
|
||||
return active_tag_class
|
||||
|
||||
|
||||
_INERT = None
|
||||
|
||||
CANDIDATES = [
|
||||
("pre-0.6.0 (no narrowing)", _variant()),
|
||||
# The URL-attribute branch requires an EXTERNAL target — the rule the markdown
|
||||
|
|
@ -91,14 +129,28 @@ CANDIDATES = [
|
|||
# which the URL-attribute branch still catches; APIM policy XML's `<base />` is
|
||||
# attribute-less and has no affordance in any renderer.
|
||||
("base-url: <base> needs a URL", _variant(drop=frozenset({"base"}))),
|
||||
("A + base-url (both)",
|
||||
("A + base-url (0.6.0)",
|
||||
_variant(drop=frozenset({"base"}), external_only=True)),
|
||||
# Not a hypothetical: the shipped predicate, unpatched. `A + base-url` is what
|
||||
# 0.6.0 shipped, so these two rows must agree — a mismatch means the code and
|
||||
# this script have drifted apart and every number below is suspect.
|
||||
# 0.7.0's pair. C1 REGRADES (a click-required carrier is MEDIUM, not HIGH);
|
||||
# D NARROWS (a tag whose whole affordance is a URL it does not carry is
|
||||
# inert). They are listed alone as well as together because they co-occur
|
||||
# hard: D strips a document's `</a>` and `<Frame>`, and what is left is the
|
||||
# `<a href=...>` C1 grades down, so each alone leaves the document blocked by
|
||||
# the other's residue. Reading either single row as "this change is cheap" is
|
||||
# the trap this script exists to prevent.
|
||||
("C1: carrier split (alone)",
|
||||
_variant(drop=frozenset({"base"}), external_only=True, carrier_split=True)),
|
||||
("D: no-URL narrowing (alone)",
|
||||
_variant(drop=frozenset({"base"}), external_only=True, no_url=True)),
|
||||
("C1 + D (0.7.0)",
|
||||
_variant(drop=frozenset({"base"}), external_only=True,
|
||||
carrier_split=True, no_url=True)),
|
||||
# Not a hypothetical: the shipped predicate, unpatched. `C1 + D` is what 0.7.0
|
||||
# ships, so these two rows must agree — a mismatch means the code and this
|
||||
# script have drifted apart and every number below is suspect.
|
||||
("PRODUCTION (as shipped)", None),
|
||||
# The CEILING: no narrowing can free more than switching the detector off.
|
||||
("NONE (ceiling)", lambda name, attrs: False),
|
||||
("NONE (ceiling)", lambda name, attrs: _INERT),
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -162,7 +214,7 @@ def main() -> None:
|
|||
print(__doc__)
|
||||
raise SystemExit(2)
|
||||
|
||||
original = ac.is_active_tag
|
||||
original = ac.active_tag_class
|
||||
try:
|
||||
for spec in specs:
|
||||
if "=" not in spec:
|
||||
|
|
@ -177,27 +229,78 @@ def main() -> None:
|
|||
n = len(texts)
|
||||
print(f"\n## {label} — {n} documents", flush=True)
|
||||
|
||||
baseline = None
|
||||
base_nonwarn = base_block = None
|
||||
shipped_before: dict[str, list] = {}
|
||||
for name, fn in CANDIDATES:
|
||||
ac.is_active_tag = original if fn is None else fn
|
||||
non_warn = sum(
|
||||
1 for t in texts
|
||||
if screen_output(t, PRESET_USER_UPLOAD).disposition is not BENIGN
|
||||
)
|
||||
if baseline is None:
|
||||
baseline = non_warn
|
||||
print(f" {name:>28}: {non_warn:4d} ({non_warn / n:5.1%})", flush=True)
|
||||
else:
|
||||
print(f" {name:>28}: {non_warn:4d} ({non_warn / n:5.1%})"
|
||||
f" frees {baseline - non_warn}", flush=True)
|
||||
ac.is_active_tag = original
|
||||
ac.active_tag_class = original if fn is None else fn
|
||||
# `screen_output(t, policy)` IS `guard(lambda: scan_output(t),
|
||||
# policy)`. Decomposed by exactly one step here so the scan — the
|
||||
# expensive part, and identical across trust tiers — runs once per
|
||||
# document instead of once per tier. Disposition still goes through
|
||||
# the shipped `guard`, so the fail-closed wrapper is not skipped.
|
||||
per_preset = {tier: [] for tier in PRESETS}
|
||||
for t in texts:
|
||||
try:
|
||||
report = scan_output(t)
|
||||
except Exception: # noqa: BLE001 — hand it back to `guard`
|
||||
report = None
|
||||
for tier, policy in PRESETS.items():
|
||||
scan_fn = (lambda: scan_output(t)) if report is None \
|
||||
else (lambda report=report: report)
|
||||
per_preset[tier].append(guard(scan_fn, policy).disposition)
|
||||
dispositions = per_preset["upload"]
|
||||
non_warn = sum(1 for d in dispositions if d is not BENIGN)
|
||||
# BOTH metrics, because they answer different questions and a
|
||||
# REGRADE is invisible to the first one. A narrowing removes the
|
||||
# finding, so a document can reach WARN; a carrier split only
|
||||
# lowers the severity, so the document stays non-WARN and merely
|
||||
# stops being hard-failed. Reporting only `non_warn` would have
|
||||
# printed "frees 0" for every carrier candidate and read as
|
||||
# "the split buys nothing" when it converts a hard block into a
|
||||
# human review — the difference a consumer actually feels.
|
||||
blocked = sum(1 for d in dispositions if d is BLOCKED)
|
||||
# Tightening is measured against the row consumers are RUNNING,
|
||||
# not against the pre-0.6.0 baseline the `frees` column subtracts
|
||||
# from. "Did this release make anything worse for someone on the
|
||||
# current version" is a different question from "how much of the
|
||||
# original over-reach is left", and only the first one belongs in
|
||||
# a release note.
|
||||
if name == _SHIPPED_BEFORE_ROW:
|
||||
shipped_before = per_preset
|
||||
if base_nonwarn is None:
|
||||
base_nonwarn, base_block = non_warn, blocked
|
||||
print(f" {name:>28}: non-WARN {non_warn:4d} ({non_warn / n:5.1%})"
|
||||
f" fail_secure {blocked:4d}", flush=True)
|
||||
continue
|
||||
# A candidate is not free just because it frees documents. Splitting
|
||||
# one finding into two puts TWO findings at MEDIUM+ in a document
|
||||
# that had one, which trips the compound overlay — so a change sold
|
||||
# as a loosening can TIGHTEN a document one tier, and on the trusted
|
||||
# preset (where nothing was hard-failed to begin with) that is the
|
||||
# only direction it can move. Reporting `unblocks` without `tightens`
|
||||
# is a one-sided number.
|
||||
row = (f" {name:>28}: non-WARN {non_warn:4d} ({non_warn / n:5.1%})"
|
||||
f" fail_secure {blocked:4d}"
|
||||
f" frees {base_nonwarn - non_warn:3d} / unblocks "
|
||||
f"{base_block - blocked:3d}")
|
||||
if shipped_before:
|
||||
tightened = {
|
||||
tier: sum(1 for before, after
|
||||
in zip(shipped_before[tier], per_preset[tier])
|
||||
if _TIER[after] > _TIER[before])
|
||||
for tier in PRESETS
|
||||
}
|
||||
row += (f" TIGHTENS vs 0.6.0: upload {tightened['upload']:3d}"
|
||||
f" trusted {tightened['trusted']:3d}")
|
||||
print(row, flush=True)
|
||||
ac.active_tag_class = original
|
||||
|
||||
names, relative, external = branch_census(texts)
|
||||
print(f" name branch : {dict(names.most_common(8))}")
|
||||
print(f" url-attr relative: {dict(relative.most_common(8))} <- A frees these")
|
||||
print(f" url-attr external: {dict(external.most_common(8))} <- A keeps these")
|
||||
finally:
|
||||
ac.is_active_tag = original
|
||||
ac.active_tag_class = original
|
||||
|
||||
print("\n---")
|
||||
print("Candidates are measured TOGETHER as well as alone: over-reach classes\n"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue