Files
IxMxAMAR 3f9e39bb44 v0.3.0: security hardening, audit fixes, 10 new ops, theme UX (matchMedia perf fix + sandbox + leaks + features)
Critical security fixes
-----------------------
- Sandbox Jinja2 in template_render + prompt_template_render (was RCE via shared workflows)
- Drop user home dir from filesystem allow-list (was arbitrary delete/overwrite)
- Confine save_image_with_manifest + sanitize filename basename
- SSRF block on http_get/http_post/notify_webhook (catches 169.254.169.254 metadata)
- Case-insensitive path confinement on Windows

Critical correctness/perf
-------------------------
- halftone_dots: O(N^2) -> O(cells*36); was freezing worker on 1024+ images
- Preserve latent dict keys (noise_mask, batch_index) in latent_math/noise/upscale
- Preserve alpha channel in invert + threshold_binary on RGBA inputs
- Force odd kernel in mask_erode/mask_dilate to prevent H+1 shape mismatch
- Snap aspect_ratio_pick output to multiples of 8 (was crashing SD UNet)

Theme engine (JS)
-----------------
- Hoist window.matchMedia() out of per-frame hot path -> recovers 200 FPS from 25
- Wrap-once draw method to stop stomping other extensions wrappers
- nodeRemoved hook + WeakMap groups + in-place canvas reuse -> three memory leaks closed
- Wire thickness now uses connections_width (the wrap was a no-op vs LiteGraph)
- onGroupAdded auto-themes new groups; budget change triggers redraw
- Gradient caching in synthwave/glassmorphic

New ops (10)
------------
- json_path (programming): dotted/bracketed path extract
- latent_nan_guard, latent_pad_crop (mask_latent)
- mask_from_depth, mask_invert (mask_latent)
- color_match_histogram, image_composite_over (image_pro/value)
- frequency_separation (image_pro/style)
- pad_to_multiple (image_pro/spatial)
- save_images_zip (io_workflow)
- llm_enhance_prompt via Ollama / LM Studio / llama.cpp (prompt)

Theme UX
--------
- Right-click "Theme..." submenu on every node (including vanilla ComfyUI nodes)
- Auto-contrast title text (WCAG AA-based flip)

Tests: 373 Python + 33 JS = 406 passing (added 33 new tests for security + features).
Full review notes in CHANGELOG.md.
2026-05-17 15:41:19 +05:30

227 lines
6.6 KiB
Python

"""Dict and list data-structure operations."""
from .. import op
@op(
op_id="dict_get",
display_name="Dict Get",
category="Data Structures",
input_schema={"required": {
"data": ("DICT", {"default": {}}),
"key": ("STRING", {"default": ""}),
"default": ("STRING", {"default": ""}),
}},
output_indices=(6,),
description="Returns data[key] if present, otherwise default. Default is a string; pass JSON if you need structured defaults.",
)
def dict_get(self, data, key, default=""):
if not isinstance(data, dict):
return (default,)
return (data.get(key, default),)
@op(
op_id="dict_set",
display_name="Dict Set",
category="Data Structures",
input_schema={"required": {
"data": ("DICT", {"default": {}}),
"key": ("STRING", {"default": ""}),
"value": ("STRING", {"default": ""}),
}},
output_indices=(5,),
description="Returns a new dict with data[key] = value. Original is not mutated.",
)
def dict_set(self, data, key, value):
new_data = dict(data) if isinstance(data, dict) else {}
new_data[key] = value
return (new_data,)
@op(
op_id="dict_keys",
display_name="Dict Keys",
category="Data Structures",
input_schema={"required": {"data": ("DICT", {"default": {}})}},
output_indices=(4,),
description="Returns sorted list of keys.",
)
def dict_keys(self, data):
if not isinstance(data, dict):
return ([],)
return (sorted(data.keys()),)
@op(
op_id="dict_values",
display_name="Dict Values",
category="Data Structures",
input_schema={"required": {"data": ("DICT", {"default": {}})}},
output_indices=(4,),
description="Returns values in key-sorted order.",
)
def dict_values(self, data):
if not isinstance(data, dict):
return ([],)
return ([data[k] for k in sorted(data.keys())],)
@op(
op_id="dict_merge",
display_name="Dict Merge",
category="Data Structures",
input_schema={"required": {
"a": ("DICT", {"default": {}}),
"b": ("DICT", {"default": {}}),
}},
output_indices=(5,),
description="Shallow merge. b's keys win on conflicts.",
)
def dict_merge(self, a, b):
out = dict(a) if isinstance(a, dict) else {}
if isinstance(b, dict):
out.update(b)
return (out,)
@op(
op_id="list_length",
display_name="List Length",
category="Data Structures",
input_schema={"required": {"data": ("LIST", {"default": []})}},
output_indices=(1,),
)
def list_length(self, data):
return (len(data),)
@op(
op_id="list_index",
display_name="List Index",
category="Data Structures",
input_schema={"required": {
"data": ("LIST", {"default": []}),
"index": ("INT", {"default": 0}),
}},
output_indices=(6,),
description="Negative indexes supported. Raises IndexError on out-of-range.",
)
def list_index(self, data, index):
if not data:
raise IndexError("list is empty")
return (data[index],)
@op(
op_id="list_slice",
display_name="List Slice",
category="Data Structures",
input_schema={"required": {
"data": ("LIST", {"default": []}),
"start": ("INT", {"default": 0}),
"stop": ("INT", {"default": 0}),
}},
output_indices=(4,),
description="stop = 0 means 'to end'.",
)
def list_slice(self, data, start=0, stop=0):
end = len(data) if stop == 0 else stop
return (list(data[start:end]),)
@op(
op_id="list_sort",
display_name="List Sort",
category="Data Structures",
input_schema={"required": {
"data": ("LIST", {"default": []}),
"reverse": ("BOOLEAN", {"default": False}),
}},
output_indices=(4,),
description="Sorts by string repr (so mixed-type lists don't crash).",
)
def list_sort(self, data, reverse=False):
return (sorted(data, key=str, reverse=bool(reverse)),)
@op(
op_id="list_reverse",
display_name="List Reverse",
category="Data Structures",
input_schema={"required": {"data": ("LIST", {"default": []})}},
output_indices=(4,),
)
def list_reverse(self, data):
return (list(reversed(data)),)
# ---------------- JSON Path / dotted-path lookups ----------------
def _walk_path(obj, path: str):
"""Walk a dotted/bracketed path through nested dicts and lists.
Supports:
`a.b.c` nested key lookup
`items[0]` list index
`items[-1]` negative index from the end
`a.b[2].c` mixed
`*` leaves the path early (returns current obj)
Returns the value at the path or raises KeyError / IndexError.
"""
if not path or path == "*":
return obj
cur = obj
# Split on `.` but keep bracket segments attached: `a.b[2].c` → ['a','b[2]','c']
parts = [p for p in path.split(".") if p]
for part in parts:
# Pull out bracketed indices/keys: `foo[2][bar]` → name="foo", idx=[2,"bar"]
name = part
bracket_indices: list = []
while "[" in name and name.endswith("]"):
head, _, rest = name.rpartition("[")
idx = rest[:-1]
bracket_indices.insert(0, idx)
name = head
if name:
if not isinstance(cur, dict):
raise KeyError(f"path {path!r}: cannot key {name!r} into {type(cur).__name__}")
cur = cur[name]
for idx in bracket_indices:
if isinstance(cur, list):
cur = cur[int(idx)]
elif isinstance(cur, dict):
# Allow quoted-key style: a["foo"]
key = idx.strip("'").strip('"')
cur = cur[key]
else:
raise KeyError(f"path {path!r}: cannot index into {type(cur).__name__}")
return cur
@op(
op_id="json_path",
display_name="JSON Path Extract",
category="Data Structures",
input_schema={"required": {
"data": ("DICT", {"default": {}}),
"path": ("STRING", {"default": ""}),
}, "optional": {
"default_on_miss": ("STRING", {"default": ""}),
}},
output_indices=(0, 5, 6),
description=(
"Extract a nested value via dotted path: `user.name`, `items[0].price`, "
"`results[-1].url`. Outputs: (text-coerced str, raw dict if dict, raw any)."
),
)
def json_path(self, data, path, default_on_miss=""):
try:
value = _walk_path(data, path)
except (KeyError, IndexError, ValueError, TypeError):
value = default_on_miss
text = "" if value is None else (value if isinstance(value, str) else str(value))
dict_out = value if isinstance(value, dict) else {}
return (text, dict_out, value)