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
+39 -2
View File
@@ -11,6 +11,7 @@ import tempfile
import unittest
from pathlib import Path
from types import MappingProxyType
from unittest import mock
from utils import inspection_semantics as semantics
from utils.inspection_semantics import SemanticsError, load_semantics
@@ -342,8 +343,13 @@ class SpecEntriesTest(unittest.TestCase):
},
)
def test_module_loads_the_table_once_at_import(self):
self.assertEqual(semantics.SEMANTICS, load_semantics())
def test_table_is_loaded_lazily_and_once(self):
# Importing the module reads nothing; the inspection path asks for it.
self.assertFalse(hasattr(semantics, "SEMANTICS"))
semantics.get_semantics.cache_clear()
first = semantics.get_semantics()
self.assertIs(first, semantics.get_semantics())
self.assertEqual(first, load_semantics())
def test_loaded_table_is_frozen(self):
table = load_semantics()
@@ -548,6 +554,37 @@ class ValidationTest(_TableCase):
with self.assertRaises(SemanticsError):
load_semantics(Path(root) / "missing.json")
def test_oversized_table_is_rejected_before_parsing(self):
text = json.dumps(_minimal()) + " " * semantics.MAX_TABLE_BYTES
with self.assertRaises(SemanticsError) as caught:
self.load_text(text)
self.assertIn("exceeds", str(caught.exception))
def test_deeply_nested_table_is_a_semantics_error(self):
# Past the nesting bound, and far past the interpreter's recursion limit.
for depth in (semantics.MAX_NESTING + 1, 100_000):
with self.subTest(depth=depth):
nested = "[" * depth + "]" * depth
text = json.dumps(_minimal()).replace(
'"selectors": {}', '"selectors": ' + nested
)
with self.assertRaises(SemanticsError) as caught:
self.load_text(text)
self.assertIn("nest", str(caught.exception))
self.assertGreater(semantics.MAX_NESTING, _depth(json.loads(TABLE.read_text())))
# Whichever interpreter limit the parser hits, it is a SemanticsError.
with mock.patch.object(semantics.json, "loads", side_effect=RecursionError):
with self.assertRaises(SemanticsError) as caught:
self.load_doc(_minimal())
self.assertIn("nests deeper", str(caught.exception))
def _depth(value, level=1):
children = value.values() if isinstance(value, dict) else value
if not isinstance(value, (dict, list)):
return level - 1
return max([level] + [_depth(item, level + 1) for item in children])
if __name__ == "__main__":
unittest.main()
+98
View File
@@ -1,9 +1,15 @@
"""Conservative prompt-pair resolution contracts, independent of ComfyUI."""
import random
import subprocess # nosec B404 - fixed interpreter command, no shell
import sys
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from unittest import mock
from utils import inspection_semantics
from utils import prompt_pair_resolver as resolver
from utils.prompt_pair_resolver import OutputAdapter, resolve_prompt_pair
@@ -1043,5 +1049,97 @@ class TestResolverProfiles(unittest.TestCase):
self.assertEqual(result["reason"], "Resolution visit limit reached")
REPO = Path(__file__).resolve().parents[1]
SEMANTICS_UNAVAILABLE = "Inspection node semantics are unavailable"
class TestSemanticsIsolation(unittest.TestCase):
"""Only inspection reads the semantics table; a bad table never breaks ingestion."""
def resolve_with_table(self, text: str, *values):
# Windows: the table is written and closed before it is read or removed.
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "semantics.json"
path.write_text(text, encoding="utf-8")
inspection_semantics.get_semantics.cache_clear()
try:
with (
mock.patch.object(inspection_semantics, "SEMANTICS_PATH", path),
mock.patch.object(resolver, "_LOGGER") as logger,
):
self.logged = logger.error.call_args_list
return [
resolve_prompt_pair(value, profile=profile)
for value in values
for profile in (
resolver.DEFAULT_PROFILE,
resolver.INSPECTION_PROFILE,
)
]
finally:
inspection_semantics.get_semantics.cache_clear()
def test_corrupt_table_leaves_default_resolution_working(self):
for text in ("{not json", "[" * 100_000, "{}"):
with self.subTest(text=text[:10]):
default, inspected = self.resolve_with_table(text, graph())
self.assertEqual(default["state"], "resolved")
self.assertEqual(default["positive"]["text"], "portrait")
self.assertEqual(inspected["state"], "unresolved")
self.assertEqual(inspected["reason"], SEMANTICS_UNAVAILABLE)
self.assertIsNone(inspected["reason_code"])
self.assertIsNone(inspected["positive"])
self.assertEqual(len(self.logged), 1)
def test_corrupt_table_only_affects_graph_inspection(self):
parameters = {"parameters": "cat\nNegative prompt: dog\nSteps: 1, Sampler: x"}
results = self.resolve_with_table("{not json", parameters, {})
self.assertEqual([item["state"] for item in results[:2]], ["resolved"] * 2)
self.assertEqual([item["state"] for item in results[2:]], ["absent"] * 2)
self.assertEqual(self.logged, [])
def test_corrupt_table_is_a_clear_inspector_outcome(self):
from utils.prompt_inspector import inspect_prompt_evidence
with tempfile.TemporaryDirectory() as root:
path = Path(root) / "semantics.json"
path.write_text("{not json", encoding="utf-8")
inspection_semantics.get_semantics.cache_clear()
try:
with (
mock.patch.object(inspection_semantics, "SEMANTICS_PATH", path),
self.assertLogs("prompt_manager.prompt_pair_resolver", "ERROR"),
):
evidence = inspect_prompt_evidence({"prompt": graph()})
finally:
inspection_semantics.get_semantics.cache_clear()
self.assertEqual(evidence["resolution"]["state"], "unresolved")
self.assertEqual(evidence["resolution"]["reason"], SEMANTICS_UNAVAILABLE)
self.assertEqual(
[evidence["prompts"][role]["status"] for role in ("positive", "negative")],
["unresolved", "unresolved"],
)
def test_default_resolution_never_imports_the_semantics_table(self):
code = (
"import sys\n"
"from utils.prompt_pair_resolver import resolve_prompt_pair\n"
"graph = {'1': {'class_type': 'CLIPTextEncode', 'inputs': {'text': 'a'}},"
" '3': {'class_type': 'KSampler',"
" 'inputs': {'positive': ['1', 0], 'negative': ['1', 0]}}}\n"
"assert resolve_prompt_pair(graph)['state'] == 'resolved'\n"
"assert 'utils.inspection_semantics' not in sys.modules\n"
)
# Fixed interpreter and literal code, no shell.
completed = subprocess.run( # nosec B603
[sys.executable, "-c", code],
cwd=REPO,
capture_output=True,
timeout=60,
check=False,
)
self.assertEqual(completed.returncode, 0, completed.stderr.decode("utf-8"))
if __name__ == "__main__":
unittest.main()
+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(