fix(inspect): bound and isolate the semantics table

Bound the semantics loader: at most 1 MiB is read, containers may nest
at most eight levels, and a RecursionError from the JSON parser becomes
a SemanticsError instead of escaping.

The table is no longer loaded at import. get_semantics() reads it on
first use and caches only a valid result, and the resolver imports it
lazily, only for a profile with semantics on. The default ingestion
profile never imports the module, so a missing or corrupt table cannot
break scan, monitor or admin import. Inspection of a prompt graph with
an unusable table is unresolved with the reason "Inspection node
semantics are unavailable" and an error log, rather than a crash.

INSPECTION_PROFILE now has semantics on; no strategy consumes the table
yet, so resolution is unchanged.
This commit is contained in:
Vito Sansevero
2026-10-19 15:00:33 -07:00
parent 5a6c0bdbc1
commit 9424cd2e35
4 changed files with 212 additions and 12 deletions
+41 -8
View File
@@ -6,15 +6,19 @@ slot)`` pairs) to a named strategy from the closed ``STRATEGIES`` set plus the
exact input names that strategy reads. Code implements each strategy; adding a
node that reuses one is a data-only change.
Only the inspection resolver profile consumes this table. The default
(ingestion) profile never reads it. The table is validated once at import and an
invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
silently changing what the inspector resolves. Every class appears in exactly
one category; classes that are not listed stay ``unsupported-node``.
Only the inspection resolver profile consumes this table, through
``get_semantics()``, which reads and validates it on first use and caches only a
valid result. Importing this module reads nothing, and the default (ingestion)
profile never imports it, so a missing or bad table can never break ingestion.
An invalid table raises ``SemanticsError`` so a bad edit fails loudly instead of
silently changing what the inspector resolves. The file is bounded in size and
nesting before it is validated. Every class appears in exactly one category;
classes that are not listed stay ``unsupported-node``.
"""
import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from types import MappingProxyType
from typing import Any, Callable, Dict, FrozenSet, Mapping, Optional, Tuple, Union
@@ -23,6 +27,9 @@ SEMANTICS_PATH = (
Path(__file__).resolve().parents[1] / "web" / "js" / "inspection-semantics.json"
)
SCHEMA_VERSION = 1
# The real table is a few KiB and four levels deep; these only reject nonsense.
MAX_TABLE_BYTES = 1024 * 1024
MAX_NESTING = 8
_NAME_LENGTH = 128
# Comfy output slots are small; this only rejects nonsense, never a real node.
_MAX_SLOT = 255
@@ -364,6 +371,19 @@ def _facts(doc: dict) -> Mapping[str, Any]:
return _freeze(facts)
def _check_nesting(doc: Any) -> None:
"""Reject containers nested deeper than ``MAX_NESTING`` (iterative walk)."""
stack = [(doc, 1)]
while stack:
value, level = stack.pop()
if not isinstance(value, (dict, list)):
continue
if level > MAX_NESTING:
raise SemanticsError(f"semantics table nests deeper than {MAX_NESTING}")
items = value.values() if isinstance(value, dict) else value
stack.extend((item, level + 1) for item in items)
def _validate(doc: Any) -> Semantics:
if not isinstance(doc, dict):
raise SemanticsError("semantics table must be an object")
@@ -388,17 +408,30 @@ def load_semantics(path: Optional[Union[str, Path]] = None) -> Semantics:
"""Read and validate a semantics table; ``SemanticsError`` on any problem."""
source = Path(path) if path is not None else SEMANTICS_PATH
try:
text = source.read_text(encoding="utf-8")
with source.open("rb") as stream:
data = stream.read(MAX_TABLE_BYTES + 1)
if len(data) > MAX_TABLE_BYTES:
raise SemanticsError(
f"semantics table {source.name} exceeds {MAX_TABLE_BYTES} bytes"
)
doc = json.loads(
text,
data.decode("utf-8"),
object_pairs_hook=_reject_duplicate_keys,
parse_constant=_reject_constant,
)
except SemanticsError:
raise
except RecursionError:
raise SemanticsError(
f"semantics table {source.name} nests deeper than {MAX_NESTING}"
) from None
except (OSError, UnicodeDecodeError, ValueError) as error:
raise SemanticsError(f"cannot read semantics table {source.name}: {error}")
_check_nesting(doc)
return _validate(doc)
SEMANTICS = load_semantics()
@lru_cache(maxsize=1)
def get_semantics() -> Semantics:
"""The repository table, loaded on first use; failures are not cached."""
return load_semantics()
+34 -2
View File
@@ -13,7 +13,8 @@ Future nodes can register declarative ``OutputAdapter`` entries by
Effective text uses a unique bounded execution record for traced paired nodes
when available; cached graph-only results remain explicitly inferred.
The resolver has no
ComfyUI, database, configuration or filesystem dependencies.
ComfyUI, database or configuration dependencies; its only file read is the
inspection profile's node semantics table (``utils.inspection_semantics``).
Two ``ResolverProfile`` values exist. ``DEFAULT_PROFILE`` (ingestion: scan, live
monitor, admin import) keeps the historical behavior exactly, including the
@@ -23,14 +24,19 @@ shared by every candidate and attribution walk, a node ceiling backstop, and
bounds on built provenance and text. It also names why a result is not resolved
(``reason_code``, and ``node`` for the first unsupported node), and a prompt graph
without a supported sampler is ``unresolved`` (``no-sampler``), never ``absent``.
When the semantics table cannot be loaded, inspection of a prompt graph is
``unresolved`` with a fixed reason; ingestion never loads the table.
"""
import logging
import re
from dataclasses import dataclass, replace
from typing import Any, Dict, Mapping, Optional, Tuple
from .pair_execution import ExecutionEvidenceError, read_pair_execution
_LOGGER = logging.getLogger("prompt_manager.prompt_pair_resolver")
@dataclass(frozen=True)
class OutputAdapter:
@@ -80,7 +86,9 @@ class ResolverProfile:
bounds linear work across the whole resolution: input keys validated or
scanned, characters stripped or joined, and provenance entries copied, each
charged before the work is done. ``None`` disables each bound.
``semantics`` and ``selection`` are reserved for later slices and stay off.
``semantics`` reads the declarative node table (inspection only; loaded
lazily so the default profile never imports it). ``selection`` is reserved
for a later slice and stays off.
"""
name: str
@@ -118,6 +126,7 @@ INSPECTION_PROFILE = ResolverProfile(
max_text_length=100_000,
node_ceiling=20_000,
legacy_node_gate=None,
semantics=True,
max_path_entries=64 * 20_000,
max_built_chars=16 * 100_000,
# About four passes over the 2 MiB role-record budget.
@@ -714,6 +723,24 @@ def resolve_prompt_pair(
return _legacy_result(result) if profile.legacy else result
_SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable"
def _inspection_semantics() -> Optional[Any]:
"""The validated node table, or ``None`` when it cannot be loaded.
Imported here, not at module level: ingestion (the default profile) must
keep working whatever state the table file is in.
"""
from .inspection_semantics import SemanticsError, get_semantics
try:
return get_semantics()
except SemanticsError as error:
_LOGGER.error("Inspection node semantics table is unusable: %s", error)
return None
def _limit_result(profile: ResolverProfile, graph: dict) -> Optional[Dict[str, Any]]:
if profile.legacy:
if (
@@ -763,6 +790,11 @@ def _resolve(
limited = _limit_result(profile, graph)
if limited is not None:
return limited
semantics = None
if profile.semantics:
semantics = _inspection_semantics()
if semantics is None:
return _result("unresolved", reason=_SEMANTICS_UNAVAILABLE)
normalized = {str(node_id): node for node_id, node in graph.items()}
if len(normalized) != len(graph):
return _result(