python-statemachine 3.2.0__py3-none-any.whl → 3.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/METADATA +7 -6
  2. python_statemachine-3.2.2.dist-info/RECORD +73 -0
  3. {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/WHEEL +1 -1
  4. statemachine/__init__.py +1 -1
  5. statemachine/callbacks.py +11 -0
  6. statemachine/contrib/diagram/__init__.py +15 -6
  7. statemachine/contrib/diagram/extract.py +3 -3
  8. statemachine/contrib/diagram/formatter.py +4 -2
  9. statemachine/contrib/diagram/graphviz.py +66 -0
  10. statemachine/contrib/diagram/sphinx_ext.py +28 -7
  11. statemachine/dispatcher.py +8 -3
  12. statemachine/engines/async_.py +224 -180
  13. statemachine/engines/base.py +257 -245
  14. statemachine/engines/sync.py +224 -108
  15. statemachine/event.py +29 -10
  16. statemachine/event_data.py +19 -3
  17. statemachine/events.py +5 -2
  18. statemachine/exceptions.py +1 -1
  19. statemachine/factory.py +126 -29
  20. statemachine/io/__init__.py +18 -11
  21. statemachine/io/actions.py +124 -15
  22. statemachine/io/evaluators.py +37 -9
  23. statemachine/io/invoke.py +12 -0
  24. statemachine/io/loader.py +17 -3
  25. statemachine/io/schemas/statechart.schema.json +8 -1
  26. statemachine/io/scxml/__init__.py +6 -3
  27. statemachine/io/scxml/processor.py +1 -1
  28. statemachine/io/scxml/reader.py +24 -9
  29. statemachine/io/system_variables.py +34 -19
  30. statemachine/locale/en/LC_MESSAGES/statemachine.po +58 -17
  31. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +66 -17
  32. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +66 -17
  33. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +60 -17
  34. statemachine/orderedset.py +12 -3
  35. statemachine/registry.py +6 -1
  36. statemachine/spec_parser.py +151 -21
  37. statemachine/state.py +47 -8
  38. statemachine/statemachine.py +42 -19
  39. python_statemachine-3.2.0.dist-info/RECORD +0 -72
  40. {python_statemachine-3.2.0.dist-info → python_statemachine-3.2.2.dist-info}/licenses/LICENSE +0 -0
statemachine/factory.py CHANGED
@@ -5,6 +5,8 @@ from . import registry
5
5
  from .callbacks import CallbackGroup
6
6
  from .callbacks import CallbackPriority
7
7
  from .callbacks import CallbackSpecList
8
+ from .callbacks import SpecReference
9
+ from .callbacks import is_unbounded_reference
8
10
  from .event import Event
9
11
  from .event import _expand_event_id
10
12
  from .exceptions import InvalidDefinition
@@ -13,12 +15,25 @@ from .graph import iterate_states
13
15
  from .graph import iterate_states_and_transitions
14
16
  from .graph import states_without_path_to_final_states
15
17
  from .i18n import _
18
+ from .spec_parser import names_in_boolean_expr
16
19
  from .state import State
17
20
  from .states import States
18
21
  from .transition import Transition
19
22
  from .transition_list import TransitionList
20
23
 
21
24
 
25
+ def _same_callable(func: Any, other: Any) -> bool:
26
+ return type(func) is type(other) and func == other
27
+
28
+
29
+ def _can_store_apart(existing: Any, owners: list[tuple[State, Any]]) -> bool:
30
+ """Only callbacks held by object can move to a key of their own: a name is still
31
+ reachable through ``self.<name>``, so it must not lose a plain method or attribute."""
32
+ return all(is_unbounded_reference(func) for _, func in owners) and (
33
+ existing is None or isinstance(existing, Event) or is_unbounded_reference(existing)
34
+ )
35
+
36
+
22
37
  class StateMachineMetaclass(type):
23
38
  "Metaclass for constructing StateMachine classes"
24
39
 
@@ -51,7 +66,7 @@ class StateMachineMetaclass(type):
51
66
  cls._abstract = True
52
67
  cls._events: dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
53
68
  cls._protected_attrs: set = set()
54
- cls._events_to_update: dict[Event, Event | None] = {}
69
+ cls._events_to_update: dict[Event, list[Event]] = {}
55
70
  cls._specs = CallbackSpecList()
56
71
  cls.prepare = cls._specs.grouper(CallbackGroup.PREPARE).add(
57
72
  "prepare_event", priority=CallbackPriority.GENERIC, is_convention=True
@@ -59,7 +74,6 @@ class StateMachineMetaclass(type):
59
74
  cls.add_inherited(bases)
60
75
  cls.add_from_attributes(attrs)
61
76
  cls._collect_class_listeners(attrs, bases)
62
- cls._unpack_builders_callbacks()
63
77
  cls._update_event_references()
64
78
 
65
79
  if not cls.states:
@@ -88,6 +102,7 @@ class StateMachineMetaclass(type):
88
102
 
89
103
  cls._check()
90
104
  cls._setup()
105
+ cls._unpack_builders_callbacks(attrs)
91
106
  cls._expand_docstring()
92
107
 
93
108
  _STATECHART_RE = re.compile(r"\{statechart:(\w+)\}")
@@ -163,14 +178,96 @@ class StateMachineMetaclass(type):
163
178
  if not any(t for t in parent.transitions if t.initial and t.target == state):
164
179
  parent.to(state, initial=True) # pragma: no cover
165
180
 
166
- def _unpack_builders_callbacks(cls):
167
- callbacks = {}
181
+ def _unpack_builders_callbacks(cls, attrs: dict[str, Any]):
182
+ names_resolved_by_name = cls._names_resolved_by_name()
183
+ relocated: dict[Any, str] = {}
184
+ for attr_name, owners in cls._nested_callbacks().items():
185
+ existing = cls.__dict__.get(attr_name)
186
+ owners = [(s, f) for s, f in owners if not _same_callable(f, existing)]
187
+ if not owners:
188
+ continue
189
+ if existing is None and len(owners) == 1:
190
+ setattr(cls, attr_name, owners[0][1])
191
+ continue
192
+ if attr_name in names_resolved_by_name or not _can_store_apart(existing, owners):
193
+ cls._raise_callback_collision(attr_name, existing, owners, attrs)
194
+ for state, func in owners:
195
+ relocated[func] = f"{state.id}:{attr_name}"
196
+ setattr(cls, relocated[func], func)
197
+ own_states = [value for value in attrs.values() if isinstance(value, State)]
198
+ cls._update_lookup_names(relocated, own_states)
199
+
200
+ def _nested_callbacks(cls) -> dict[str, list[tuple[State, Any]]]:
201
+ """Distinct callables declared under each name, with the state body declaring them."""
202
+ claims: dict[str, list[tuple[State, Any]]] = {}
168
203
  for state in iterate_states(cls.states):
169
204
  if state._callbacks:
170
- callbacks.update(state._callbacks)
171
- del state._callbacks
172
- for key, value in callbacks.items():
173
- setattr(cls, key, value)
205
+ for attr_name, func in state._callbacks.items():
206
+ owners = claims.setdefault(attr_name, [])
207
+ if not any(_same_callable(func, other) for _, other in owners):
208
+ owners.append((state, func))
209
+ state._callbacks = None
210
+ return claims
211
+
212
+ def _update_lookup_names(cls, relocated: dict[Any, str], own_states: list[State]):
213
+ """Inherited states share their specs with the base class, which must keep its own."""
214
+ if not relocated:
215
+ return
216
+ for visited in iterate_states_and_transitions(own_states):
217
+ for spec in visited._specs:
218
+ if spec.reference is SpecReference.CALLABLE and spec.func in relocated:
219
+ spec.lookup_name = relocated[spec.func]
220
+
221
+ def _all_specs(cls):
222
+ yield cls._specs
223
+ for visited in iterate_states_and_transitions(cls.states):
224
+ yield visited._specs
225
+
226
+ def _names_resolved_by_name(cls) -> set[str]:
227
+ names: set[str] = set()
228
+ for specs in cls._all_specs():
229
+ for spec in specs:
230
+ if spec.may_contain_boolean_expression:
231
+ names.update(names_in_boolean_expr(spec.attr_name))
232
+ elif spec.reference is SpecReference.NAME:
233
+ names.add(spec.attr_name)
234
+ return names
235
+
236
+ def _raise_callback_collision(
237
+ cls,
238
+ attr_name: str,
239
+ existing: Any,
240
+ owners: list[tuple[State, Any]],
241
+ attrs: dict[str, Any],
242
+ ):
243
+ class_body_names = set(attrs)
244
+ class_body_names.update(
245
+ value.attr_name for value in attrs.values() if getattr(value, "attr_name", None)
246
+ )
247
+ state, func = owners[0]
248
+ if existing is None:
249
+ conflict = _("is declared in both the '{}' and the '{}' state bodies").format(
250
+ state.id, owners[1][0].id
251
+ )
252
+ elif isinstance(existing, Event):
253
+ conflict = _("in the '{}' state body clashes with the event '{}'").format(
254
+ state.id, existing.id
255
+ )
256
+ elif attr_name in class_body_names:
257
+ conflict = _("is declared in both the '{}' state body and the '{}' class body").format(
258
+ state.id, cls.__name__
259
+ )
260
+ else:
261
+ conflict = _("in the '{}' state body clashes with the '{}' attribute of '{}'").format(
262
+ state.id, attr_name, cls.__name__
263
+ )
264
+ name = func.__name__ if getattr(func, "is_event", False) else attr_name
265
+ raise InvalidDefinition(
266
+ _(
267
+ "Callback '{}' {}. Nested state bodies share the class namespace. "
268
+ "Rename one of them."
269
+ ).format(name, conflict)
270
+ )
174
271
 
175
272
  def _check(cls):
176
273
  has_states = bool(cls.states)
@@ -305,21 +402,21 @@ class StateMachineMetaclass(type):
305
402
  if isinstance(value, State):
306
403
  cls.add_state(key, value)
307
404
  elif isinstance(value, (Transition, TransitionList)):
308
- event_id = _expand_event_id(key)
309
- cls.add_event(event=Event(transitions=value, id=event_id))
405
+ for event_id in _expand_event_id(key):
406
+ cls.add_event(event=Event(transitions=value, id=event_id))
310
407
  elif isinstance(value, (Event,)):
311
- if value._has_real_id:
312
- event_id = value.id
313
- else:
314
- event_id = _expand_event_id(key)
315
- new_event = Event(
316
- transitions=value._transitions,
317
- id=event_id,
318
- name=value.name,
319
- )
320
- cls.add_event(event=new_event, old_event=value)
408
+ event_ids = [value.id] if value._has_real_id else _expand_event_id(key)
409
+ for event_id in event_ids:
410
+ new_event = Event(
411
+ transitions=value._transitions,
412
+ id=event_id,
413
+ name=value.name,
414
+ delay=value.delay,
415
+ internal=value.internal,
416
+ )
417
+ cls.add_event(event=new_event, old_event=value)
321
418
  # Ensure the event is accessible by the Python attribute name
322
- if event_id != key:
419
+ if key not in event_ids:
323
420
  setattr(cls, key, new_event)
324
421
  elif getattr(value, "attr_name", None):
325
422
  cls._add_unbounded_callback(key, value)
@@ -334,7 +431,8 @@ class StateMachineMetaclass(type):
334
431
  # machinery that is stored at ``func.attr_name``
335
432
  setattr(cls, func.attr_name, func)
336
433
  if func.is_event:
337
- cls.add_event(event=Event(func._transitions, id=attr_name))
434
+ for event_id in _expand_event_id(attr_name):
435
+ cls.add_event(event=Event(func._transitions, id=event_id))
338
436
 
339
437
  def add_state(cls, id, state: State):
340
438
  state._set_id(id)
@@ -357,8 +455,7 @@ class StateMachineMetaclass(type):
357
455
  old_event: "Event | None" = None,
358
456
  ):
359
457
  if not event._has_real_id:
360
- if event not in cls._events_to_update:
361
- cls._events_to_update[event] = None
458
+ cls._events_to_update.setdefault(event, [])
362
459
  return
363
460
 
364
461
  transitions = event._transitions
@@ -370,20 +467,20 @@ class StateMachineMetaclass(type):
370
467
  setattr(cls, event.id, event)
371
468
 
372
469
  if old_event is not None:
373
- cls._events_to_update[old_event] = event
470
+ cls._events_to_update.setdefault(old_event, []).append(event)
374
471
 
375
472
  return cls._events[event]
376
473
 
377
474
  def _update_event_references(cls):
378
- for old_event, new_event in cls._events_to_update.items():
475
+ for old_event, new_events in cls._events_to_update.items():
379
476
  for state in cls.states:
380
477
  for transition in state.transitions:
381
- if transition._events.match(old_event):
382
- if new_event is None:
478
+ if old_event in transition._events:
479
+ if not new_events:
383
480
  raise InvalidDefinition(
384
481
  _("An event in the '{}' has no id.").format(transition)
385
482
  )
386
- transition.events._replace(old_event, new_event)
483
+ transition.events._replace(old_event, new_events)
387
484
 
388
485
  cls._events_to_update = {}
389
486
 
@@ -11,19 +11,26 @@ whether it was authored in SCXML, JSON or YAML.
11
11
  Security
12
12
  --------
13
13
 
14
- Loading a document compiles its guards, datamodel expressions and executable content into
15
- callables. Because a document may come from a semi-trusted source, loading is **secure by
16
- default** (``trusted=False``): expressions are evaluated by a **restricted AST-whitelist
17
- evaluator** that cannot reach builtins, dunder attributes, or arbitrary calls, and
18
- ``<script>`` / ``script`` (arbitrary code) is rejected. This mirrors ``yaml.safe_load`` and
19
- keeps loading from turning into arbitrary code execution.
14
+ A statechart is an *executable document*, not inert data, so loading one is closer to importing
15
+ code than to parsing JSON. Because a document may come from a semi-trusted source, loading is
16
+ **secure by default** (``trusted=False``): expressions are evaluated by a **restricted
17
+ AST-allowlist evaluator** that cannot reach builtins, dunder attributes or arbitrary calls;
18
+ ``<script>`` / ``script`` (arbitrary code) is rejected; external resource references
19
+ (``<data src>`` / ``<invoke src>``, which read local files) are rejected; write targets are
20
+ confined to public model attributes; and ``**``/``*`` are magnitude-capped.
20
21
 
21
- Passing ``trusted=True`` restores full ``eval``/``exec`` evaluation and enables ``script``.
22
- In that mode a document is equivalent to executable Python (much like :mod:`pickle`), so
23
- **only load ``trusted`` documents from sources you control** (hand-authored documents, the
24
- output of your own tooling, the W3C conformance suite).
22
+ Passing ``trusted=True`` restores full ``eval``/``exec`` evaluation, enables ``script`` and
23
+ allows external ``src`` references. In that mode a document is equivalent to executable Python
24
+ (much like :mod:`pickle`), so **only load ``trusted`` documents from sources you control**
25
+ (hand-authored documents, the output of your own tooling, the W3C conformance suite).
25
26
 
26
- See the GHSA-v4jc-pm6r-3vj8 advisory for details.
27
+ The intended use of ``trusted=False`` is your own dynamic definitions (a no-code editor, your
28
+ tooling, config you control); it is *hardening*, not a sandbox. It guarantees confidentiality
29
+ and integrity, not availability: a *running* machine can still exhaust resources (eventless
30
+ loops, large ``<foreach>``, recursive ``<invoke>``), so run genuinely untrusted documents under
31
+ your own timeout/resource limits or OS-level isolation. See the GHSA-v4jc-pm6r-3vj8,
32
+ GHSA-fj3w-533r-fvf6, GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc and GHSA-r8gj-366q-cgvj advisories,
33
+ and ``docs/io/security.md``, for details.
27
34
  """
28
35
 
29
36
  from .class_factory import ActionProtocol
@@ -8,13 +8,16 @@ is format-neutral: it is shared by the SCXML, JSON and YAML readers.
8
8
 
9
9
  import logging
10
10
  import re
11
+ import types
11
12
  from collections.abc import Callable
12
13
  from itertools import chain
13
14
  from typing import Any
15
+ from urllib.parse import urlparse
14
16
  from uuid import uuid4
15
17
 
16
18
  from ..event import BoundEvent
17
19
  from ..event import Event
20
+ from ..event import raise_delayed_internal
18
21
  from ..statemachine import StateChart
19
22
  from .evaluators import Evaluator
20
23
  from .evaluators import protected_attrs
@@ -37,6 +40,80 @@ logger = logging.getLogger(__name__)
37
40
  _debug = logger.debug if logger.isEnabledFor(logging.DEBUG) else lambda *a, **k: None
38
41
 
39
42
 
43
+ def _ensure_writable_target(name: str, descriptor: str) -> None:
44
+ """Reject a document-controlled write target that isn't a plain public attribute.
45
+
46
+ Executable content chooses the *name* it writes to: ``<assign location>`` (each dotted
47
+ segment), ``<foreach item>``/``index``, ``<data id>`` and ``<send>``/``<invoke>``
48
+ ``idlocation``. A private, dunder or engine-protected name lets an untrusted document
49
+ reach internals or, by traversing ``__class__``, corrupt the shared model class
50
+ process-wide (GHSA-v3qq-3xvg-m77g, GHSA-4857-ggqc-p3jc). Only a valid, public (no
51
+ leading ``_``), non-protected identifier may be written. Raised as ``ValueError`` so the
52
+ engine routes it to ``error.execution`` like any other executable-content failure.
53
+ """
54
+ if not name.isidentifier() or name.startswith("_") or name in protected_attrs:
55
+ raise ValueError(
56
+ f"{descriptor} cannot write to a private, dunder or protected name: {name!r}"
57
+ )
58
+
59
+
60
+ def _reject_type_or_module(obj: Any, descriptor: str) -> None:
61
+ """Reject traversing through or writing onto a class or module.
62
+
63
+ A class (``isinstance(obj, type)``) or module is shared process-wide, so a ``setattr``
64
+ on it (or a hop through it) corrupts every user of that class/module, not just this
65
+ machine. Even if an untrusted expression manages to obtain such an object as a value
66
+ (e.g. via a leaked engine capability), the write target guard blocks the mutation
67
+ (GHSA-v3qq-3xvg-m77g). Plain data instances are unaffected. Raised as ``ValueError`` so
68
+ the engine routes it to ``error.execution``.
69
+ """
70
+ if isinstance(obj, type) or isinstance(obj, types.ModuleType):
71
+ raise ValueError(f"{descriptor} cannot traverse or write onto a class or module: {obj!r}")
72
+
73
+
74
+ def _reject_engine_capability(obj: Any, descriptor: str) -> None:
75
+ """Reject traversing through or writing onto a live engine-capability object.
76
+
77
+ Beyond classes and modules (:func:`_reject_type_or_module`), the engine's own runtime
78
+ objects are shared, mutable capabilities: the machine
79
+ (:class:`~statemachine.statemachine.StateChart`, and thus ``StateMachine``), the io
80
+ :class:`~statemachine.io.interpreter.Interpreter`, the core
81
+ :class:`~statemachine.state.State` / :class:`~statemachine.transition.Transition` /
82
+ :class:`~statemachine.event.Event`, the system-variable facades
83
+ (:class:`~statemachine.io.system_variables.EventDataWrapper` /
84
+ :class:`~statemachine.io.system_variables.IOProcessor`) and the event carriers
85
+ (:class:`~statemachine.event_data.EventData` / :class:`~statemachine.event_data.TriggerData`).
86
+ A ``setattr`` on any of them (or a hop through one) mutates state shared across sibling
87
+ machine instances of the same loaded class. Even if some public alias ever leaks such
88
+ an object as a value, this blocks pivoting the write onto a shared object
89
+ (GHSA-v3qq-3xvg-m77g). The datamodel container (:class:`~statemachine.model.Model`, the
90
+ legitimate ``machine.model`` root) and plain data (dict/list/str/int/ordinary app model
91
+ instances) are unaffected. Lazy imports keep this off the module import cycle. Raised as
92
+ ``ValueError`` so the engine routes it to ``error.execution``.
93
+ """
94
+ from ..event_data import EventData
95
+ from ..event_data import TriggerData
96
+ from ..state import State
97
+ from ..transition import Transition
98
+ from .interpreter import Interpreter
99
+ from .system_variables import EventDataWrapper
100
+ from .system_variables import IOProcessor
101
+
102
+ engine_types = (
103
+ StateChart,
104
+ Interpreter,
105
+ State,
106
+ Transition,
107
+ Event,
108
+ EventDataWrapper,
109
+ IOProcessor,
110
+ EventData,
111
+ TriggerData,
112
+ )
113
+ if isinstance(obj, engine_types):
114
+ raise ValueError(f"{descriptor} cannot traverse or write onto an engine object: {obj!r}")
115
+
116
+
40
117
  class ParseTime:
41
118
  pattern = re.compile(r"(\d+(?:\.\d+)?|\.\d+)(s|ms)")
42
119
  """CSS2 time literal: a number (``5``, ``1.5``, ``.5``) followed by ``s`` or ``ms``."""
@@ -164,20 +241,30 @@ class Assign(CallableAction):
164
241
  value = self._expr(*args, **kwargs) # type: ignore[misc]
165
242
 
166
243
  *path, attr = self.action.location.split(".")
244
+ descriptor = f"<assign> 'location' ({self.action.location!r})"
167
245
  obj = machine.model
246
+ # Validate every hop (initial, intermediate and final): traversing a private/dunder
247
+ # object (e.g. ``__class__``) or writing a protected name is how an untrusted document
248
+ # escapes the datamodel, validating only the final segment left that open. A class or
249
+ # module (``_reject_type_or_module``) and a live engine-capability instance
250
+ # (``_reject_engine_capability``: machine/interpreter/State/Transition/Event and the
251
+ # system-variable facades) are additionally rejected as a hop or write target, so even
252
+ # a value that leaked such an object cannot be pivoted onto shared state
253
+ # (GHSA-v3qq-3xvg-m77g).
168
254
  for p in path:
255
+ _ensure_writable_target(p, descriptor)
256
+ _reject_type_or_module(obj, descriptor)
257
+ _reject_engine_capability(obj, descriptor)
169
258
  obj = getattr(obj, p)
170
259
 
171
- if not attr.isidentifier() or not (hasattr(obj, attr) or attr in kwargs):
260
+ _ensure_writable_target(attr, descriptor)
261
+ _reject_type_or_module(obj, descriptor)
262
+ _reject_engine_capability(obj, descriptor)
263
+ if not (hasattr(obj, attr) or attr in kwargs):
172
264
  raise ValueError(
173
- f"<assign> 'location' must be a valid Python attribute name and must be declared, "
265
+ f"<assign> 'location' must be declared before assignment, "
174
266
  f"got: {self.action.location}"
175
267
  )
176
- if attr in protected_attrs:
177
- raise ValueError(
178
- f"<assign> 'location' cannot assign to a protected attribute: "
179
- f"{self.action.location}"
180
- )
181
268
  setattr(obj, attr, value)
182
269
  _debug("Assign: %s = %r", self.action.location, value)
183
270
 
@@ -242,10 +329,9 @@ def create_foreach_action_callable(action: ForeachAction, evaluator: Evaluator)
242
329
  except Exception as e:
243
330
  raise ValueError(f"Error evaluating <foreach> 'array' expression: {e}") from e
244
331
 
245
- if not action.item.isidentifier():
246
- raise ValueError(
247
- f"<foreach> 'item' must be a valid Python attribute name, got: {action.item}"
248
- )
332
+ _ensure_writable_target(action.item, "<foreach> 'item'")
333
+ if action.index:
334
+ _ensure_writable_target(action.index, "<foreach> 'index'")
249
335
  for index, item in enumerate(array):
250
336
  # Assign the item and optionally the index
251
337
  setattr(machine.model, action.item, item)
@@ -315,6 +401,9 @@ def create_send_action_callable( # noqa: C901
315
401
  ) -> Callable:
316
402
  content: Any = ()
317
403
  _valid_targets = (None, "#_internal", "internal", "#_parent", "parent")
404
+ internal = action.target in ("#_internal", "internal")
405
+ if internal and (action.delay or action.delayexpr):
406
+ raise_delayed_internal(action.event or action.eventexpr or "")
318
407
  if action.content:
319
408
  content = (evaluator.eval_literal(action.content),)
320
409
  delay_expr = (
@@ -348,12 +437,11 @@ def create_send_action_callable( # noqa: C901
348
437
  _send_to_parent(action, evaluator, **kwargs)
349
438
  return
350
439
 
351
- internal = target in ("#_internal", "internal")
352
-
353
440
  send_id = None
354
441
  if action.id:
355
442
  send_id = action.id
356
443
  elif action.idlocation:
444
+ _ensure_writable_target(action.idlocation, "<send> 'idlocation'")
357
445
  send_id = uuid4().hex
358
446
  setattr(machine.model, action.idlocation, send_id)
359
447
 
@@ -408,12 +496,33 @@ def create_script_action_callable(action: ScriptAction, evaluator: Evaluator) ->
408
496
  return script_action
409
497
 
410
498
 
499
+ def _resolve_data_src(action: DataItem, evaluator: Evaluator) -> "str | None":
500
+ """Read a ``<data src="file:…">`` reference at compile time, gated on trust.
501
+
502
+ Only the ``file`` scheme is resolved (mirroring the SCXML reader's historical
503
+ behaviour). Reading a local file named by an untrusted document is a confidentiality
504
+ risk, so the restricted evaluator rejects it; ``trusted=True`` reads it as before.
505
+ Runs inside the document's base directory (``loader._build`` ``_chdir``), so relative
506
+ paths resolve against the document.
507
+ """
508
+ if action.src is None:
509
+ return None
510
+ src_parsed = urlparse(action.src)
511
+ if src_parsed.scheme != "file":
512
+ return None
513
+ evaluator.ensure_external_src_allowed(f"<data id={action.id!r} src=...>")
514
+ with open(src_parsed.path) as f:
515
+ return f.read()
516
+
517
+
411
518
  def _create_dataitem_callable(action: DataItem, evaluator: Evaluator) -> Callable:
519
+ content = action.content if action.content else _resolve_data_src(action, evaluator)
412
520
  expr_fn = evaluator.compile_value(action.expr) if action.expr else None
413
- content_fn = evaluator.compile_value(action.content) if action.content else None
521
+ content_fn = evaluator.compile_value(content) if content else None
414
522
 
415
523
  def data_initializer(**kwargs):
416
524
  machine: StateChart = kwargs["machine"]
525
+ _ensure_writable_target(action.id, "<data> 'id'")
417
526
 
418
527
  # Check for invoke param overrides — params from parent override child defaults
419
528
  invoke_params = getattr(machine, "_invoke_params", None)
@@ -432,7 +541,7 @@ def _create_dataitem_callable(action: DataItem, evaluator: Evaluator) -> Callabl
432
541
  try:
433
542
  value = content_fn(**kwargs)
434
543
  except Exception:
435
- value = action.content
544
+ value = content
436
545
  else:
437
546
  value = None
438
547
 
@@ -5,7 +5,7 @@ neutral IR (:mod:`statemachine.io.model`) into callables, deciding *how* they ar
5
5
  evaluated:
6
6
 
7
7
  - :class:`RestrictedEvaluator` (secure default): compiles expressions with the
8
- AST-whitelist evaluator (:func:`statemachine.spec_parser.parse_expr`), which
8
+ AST-allowlist evaluator (:func:`statemachine.spec_parser.parse_expr`), which
9
9
  cannot reach builtins, dunder attributes, or arbitrary calls. ``<script>`` and
10
10
  raw-Python evaluation are rejected because they are arbitrary code.
11
11
  - :class:`PythonEvaluator` (opt-in, ``trusted=True``): preserves the legacy
@@ -18,9 +18,13 @@ but name resolution and value computation still happen at call time, so runtime
18
18
  errors (``NameError``, ``TypeError``) keep flowing to ``error.execution`` as
19
19
  before.
20
20
 
21
+ The evaluator is also the trust gate for external resource references: an external
22
+ ``<data src>`` / ``<invoke src>`` reads a local file, so the restricted evaluator rejects
23
+ it via :meth:`ensure_external_src_allowed` (the trusted one allows it).
24
+
21
25
  This module is format-neutral: it is shared by the SCXML, JSON and YAML readers.
22
26
  See the :mod:`statemachine.io.scxml` package docstring and the GHSA-v4jc-pm6r-3vj8
23
- advisory for the rationale.
27
+ (code execution) and GHSA-fj3w-533r-fvf6 (file disclosure) advisories for the rationale.
24
28
  """
25
29
 
26
30
  import ast
@@ -82,13 +86,17 @@ def _resolvable_model_attr(name: str, model) -> bool:
82
86
  def variable_hook(name: str) -> Callable:
83
87
  """Resolve a datamodel name at call time.
84
88
 
85
- Looks the name up in the call kwargs first (engine-provided variables such as
86
- ``_event``, ``_name``, ``machine``), then falls back to a (non-protected, non-private)
87
- attribute of ``machine.model``. Mirrors the namespace built by :func:`_eval`.
89
+ Looks the name up in the call kwargs first (SCXML system variables such as
90
+ ``_event``, ``_name``), then falls back to a (non-protected, non-private) attribute of
91
+ ``machine.model``. Names in :data:`~statemachine.event._event_data_kwargs` (``machine``,
92
+ ``model``, ``event``, ``transition``, ...) are the live engine-capability objects; they
93
+ are **withheld** from the kwargs lookup so an untrusted expression cannot obtain the
94
+ engine and pivot through it onto shared classes (GHSA-v3qq-3xvg-m77g). The ``_``-prefixed
95
+ system variables are not engine objects and stay resolvable.
88
96
  """
89
97
 
90
98
  def resolver(*args, **kwargs):
91
- if name in kwargs:
99
+ if name not in _event_data_kwargs and name in kwargs:
92
100
  return kwargs[name]
93
101
  model = kwargs["machine"].model
94
102
  if _resolvable_model_attr(name, model):
@@ -109,7 +117,7 @@ def cond_variable_hook(name: str) -> Callable:
109
117
  """
110
118
 
111
119
  def resolver(*args, **kwargs):
112
- if name in kwargs:
120
+ if name not in _event_data_kwargs and name in kwargs:
113
121
  return kwargs[name]
114
122
  model = kwargs["machine"].model
115
123
  if _resolvable_model_attr(name, model):
@@ -166,9 +174,20 @@ class Evaluator(Protocol):
166
174
  """Evaluate inline ``<content>`` as a literal, falling back to the raw string."""
167
175
  ...
168
176
 
177
+ def ensure_external_src_allowed( # pragma: no cover - structural Protocol
178
+ self, descriptor: str
179
+ ) -> None:
180
+ """Guard external resource references (``<data src>`` / ``<invoke src>``).
181
+
182
+ Reading a local file named by an untrusted document is a confidentiality risk, so
183
+ it is gated on the trust level just like ``<script>``. ``descriptor`` names the
184
+ offending element for the error message.
185
+ """
186
+ ...
187
+
169
188
 
170
189
  class RestrictedEvaluator:
171
- """Secure default: AST-whitelist evaluation, no ``eval``/``exec``.
190
+ """Secure default: AST-allowlist evaluation, no ``eval``/``exec``.
172
191
 
173
192
  ``<script>`` and any expression outside the supported subset (method calls,
174
193
  dunder attribute access, lambdas, comprehensions, ...) are rejected at
@@ -208,6 +227,12 @@ class RestrictedEvaluator:
208
227
  except (ValueError, SyntaxError):
209
228
  return content
210
229
 
230
+ def ensure_external_src_allowed(self, descriptor: str) -> None:
231
+ raise InvalidDefinition(
232
+ f"{descriptor} loads a local file and is disabled by default. Pass "
233
+ "trusted=True to enable it, and only for trusted sources."
234
+ )
235
+
211
236
  @staticmethod
212
237
  def _compile(expr: str, hook: Callable) -> Callable:
213
238
  try:
@@ -235,7 +260,7 @@ class PythonEvaluator:
235
260
 
236
261
  def compile_bool(self, expr: str) -> Callable:
237
262
  # Intentionally uses ``eval`` (not ``parse_expr``): the whole point of the
238
- # trusted evaluator is to accept guards the restricted AST whitelist rejects —
263
+ # trusted evaluator is to accept guards the restricted AST allowlist rejects —
239
264
  # method/function calls, builtins, etc. (e.g. SCXML conformance conds like
240
265
  # ``_event.data.get('x') == 1`` or ``hasattr(_event, 'name')``). Routing this
241
266
  # through ``parse_expr`` would make trusted mode no more capable than the
@@ -263,6 +288,9 @@ class PythonEvaluator:
263
288
  except (NameError, SyntaxError, TypeError):
264
289
  return content
265
290
 
291
+ def ensure_external_src_allowed(self, descriptor: str) -> None:
292
+ return None
293
+
266
294
 
267
295
  def evaluator_for(trusted: bool = False) -> Evaluator:
268
296
  """Return the evaluation strategy for the given trust level (the wiring point).
statemachine/io/invoke.py CHANGED
@@ -20,6 +20,7 @@ from typing import Any
20
20
  from ..invoke import IInvoke
21
21
  from ..invoke import InvokeContext
22
22
  from .actions import ExecuteBlock
23
+ from .actions import _ensure_writable_target
23
24
  from .evaluators import Evaluator
24
25
  from .model import InvokeDefinition
25
26
  from .model import StateMachineDefinition
@@ -57,6 +58,12 @@ class Invoker:
57
58
  self._base_dir: str = base_dir
58
59
  self._evaluator: Evaluator = evaluator
59
60
 
61
+ # A static ``src`` is known at load time, so reject it now (like <data src> /
62
+ # <script>) for a clean, propagating error. A dynamic ``srcexpr`` can only be
63
+ # resolved at runtime and is gated again in ``_resolve_content``.
64
+ if definition.content is None and definition.src is not None:
65
+ self._evaluator.ensure_external_src_allowed("<invoke src=...>")
66
+
60
67
  # Duck-typed attributes for InvokeManager
61
68
  self.invoke_id: "str | None" = definition.id
62
69
  self.idlocation: "str | None" = definition.idlocation
@@ -76,6 +83,7 @@ class Invoker:
76
83
 
77
84
  # Store invokeid in idlocation if specified
78
85
  if self.idlocation:
86
+ _ensure_writable_target(self.idlocation, "<invoke> 'idlocation'")
79
87
  setattr(machine.model, self.idlocation, ctx.invokeid)
80
88
 
81
89
  # Resolve invoke type
@@ -176,6 +184,10 @@ class Invoker:
176
184
  else:
177
185
  return None
178
186
 
187
+ # Reading a local file named by an untrusted document is a confidentiality risk,
188
+ # so it is gated on the trust level (mirrors <script> / <data src>).
189
+ self._evaluator.ensure_external_src_allowed("<invoke src=...>")
190
+
179
191
  # Handle file: URIs and relative paths
180
192
  if src.startswith("file:"):
181
193
  path = Path(src.removeprefix("file:"))