6363
6464_ENV_REFERENCE_RE = re .compile (r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)" )
6565
66+ # Allowed identifier shape for object-attribute steps in declarative state paths
67+ _SAFE_PATH_SEGMENT_RE = re .compile (r"^[A-Za-z][A-Za-z0-9_]*$" )
68+
6669
6770@dataclass (frozen = True )
6871class DeclarativeEnvConfig :
@@ -266,6 +269,9 @@ class DeclarativeWorkflowState:
266269 - Conversation: Conversation history
267270 """
268271
272+ # Sentinel marking "no prior value" for temporary-key bookkeeping.
273+ _MISSING : Any = object ()
274+
269275 def __init__ (self , state : State , env_config : DeclarativeEnvConfig | None = None ):
270276 """Initialize with a State instance.
271277
@@ -331,16 +337,21 @@ def set_state_data(self, data: DeclarativeStateData) -> None:
331337 def get (self , path : str , default : Any = None ) -> Any :
332338 """Get a value from the state using a dot-notated path.
333339
340+ Dict-keyed segments may use arbitrary string keys (e.g. UUIDs in
341+ ``System.conversations.<id>.messages``). Segments that would resolve
342+ via object-attribute access must be valid declarative identifiers
343+ (``[A-Za-z][A-Za-z0-9_]*``); other shapes return ``default``.
344+
334345 Args:
335346 path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
336347 default: Default value if path doesn't exist
337348
338349 Returns:
339- The value at the path, or default if not found
350+ The value at the path, or default if not found or unreachable.
340351 """
341352 state_data = self .get_state_data ()
342353 parts = path .split ("." )
343- if not parts :
354+ if not parts or any ( not p for p in parts ) :
344355 return default
345356
346357 namespace = parts [0 ]
@@ -377,10 +388,19 @@ def get(self, path: str, default: Any = None) -> Any:
377388 obj = obj .get (part , default ) # type: ignore[union-attr]
378389 if obj is default :
379390 return default
380- elif hasattr (obj , part ): # type: ignore[arg-type]
381- obj = getattr (obj , part ) # type: ignore[arg-type]
382391 else :
383- return default
392+ # Attribute access is only allowed for safe declarative identifiers.
393+ if not _SAFE_PATH_SEGMENT_RE .match (part ):
394+ logger .warning (
395+ "DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r" ,
396+ part ,
397+ path ,
398+ )
399+ return default
400+ if hasattr (obj , part ): # type: ignore[arg-type]
401+ obj = getattr (obj , part ) # type: ignore[arg-type]
402+ else :
403+ return default
384404
385405 return obj # type: ignore[return-value]
386406
@@ -392,12 +412,14 @@ def set(self, path: str, value: Any) -> None:
392412 value: The value to set
393413
394414 Raises:
395- ValueError: If attempting to set Workflow.Inputs (which is read-only)
415+ ValueError: If ``path`` is empty or contains empty segments
416+ (e.g. ``"Local."``, ``"Local..foo"``), or if attempting to set
417+ ``Workflow.Inputs`` (which is read-only).
396418 """
397419 state_data = self .get_state_data ()
398420 parts = path .split ("." )
399- if not parts :
400- return
421+ if not parts or any ( not p for p in parts ) :
422+ raise ValueError ( f"Invalid path { path !r } : empty segments are not allowed" )
401423
402424 namespace = parts [0 ]
403425 remaining = parts [1 :]
@@ -453,7 +475,16 @@ def append(self, path: str, value: Any) -> None:
453475 Args:
454476 path: Dot-notated path to a list
455477 value: The value to append
478+
479+ Raises:
480+ ValueError: If ``path`` is empty or contains empty segments
481+ (e.g. ``"Local."``, ``"Local..foo"``), or if the existing
482+ value at ``path`` is not a list.
456483 """
484+ parts = path .split ("." )
485+ if not parts or any (not p for p in parts ):
486+ raise ValueError (f"Invalid path { path !r} : empty segments are not allowed" )
487+
457488 existing = self .get (path )
458489 if existing is None :
459490 self .set (path , [value ])
@@ -464,6 +495,15 @@ def append(self, path: str, value: Any) -> None:
464495 else :
465496 raise ValueError (f"Cannot append to non-list at path '{ path } '" )
466497
498+ def _clear_local_path (self , name : str ) -> None :
499+ """Remove ``name`` from the ``Local`` namespace, if present."""
500+ state_data = self .get_state_data ()
501+ local = state_data .get ("Local" )
502+ if local is None or name not in local :
503+ return
504+ local .pop (name , None )
505+ self .set_state_data (state_data )
506+
467507 def eval (self , expression : str ) -> Any :
468508 """Evaluate a PowerFx expression with the current state.
469509
@@ -504,53 +544,64 @@ def eval(self, expression: str) -> Any:
504544 return result
505545
506546 # Pre-process nested custom functions (e.g., Upper(MessageText(...)))
507- # Replace them with their evaluated results before sending to PowerFx
508- formula = self ._preprocess_custom_functions (formula )
509-
510- if Engine is None :
511- raise RuntimeError (
512- f"PowerFx is not available (dotnet runtime not installed). "
513- f"Expression '={ formula [:80 ]} ' cannot be evaluated. "
514- f"Install dotnet and the powerfx package for full PowerFx support."
515- )
516-
517- symbols = self ._to_powerfx_symbols ()
518- # Use setlocale(category) query form so we can restore the exact prior value.
519- # getlocale() returns a normalized tuple and is not always a lossless
520- # round-trip for setlocale across platforms/locales.
521- original_numeric_locale = locale .setlocale (locale .LC_NUMERIC )
547+ # and run PowerFx. The finally below restores any temporary state
548+ # written during preprocessing, regardless of where execution exits.
549+ temp_writes : list [tuple [str , Any ]] = []
550+
522551 try :
523- for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES :
524- try :
525- locale .setlocale (locale .LC_NUMERIC , locale_candidate )
526- break
527- except locale .Error :
528- continue
552+ formula = self ._preprocess_custom_functions (formula , temp_writes )
529553
530- engine = Engine ()
531- try :
532- from System .Globalization import ( # pyright: ignore[reportMissingImports]
533- CultureInfo , # pyright: ignore[reportUnknownVariableType]
554+ if Engine is None :
555+ raise RuntimeError (
556+ f"PowerFx is not available (dotnet runtime not installed). "
557+ f"Expression '={ formula [:80 ]} ' cannot be evaluated. "
558+ f"Install dotnet and the powerfx package for full PowerFx support."
534559 )
535- except ImportError :
536- return engine .eval (formula , symbols = symbols , locale = _POWERFX_EVAL_LOCALE )
537560
538- original_culture = cast (Any , CultureInfo .CurrentCulture ) # pyright: ignore[reportUnknownMemberType]
561+ symbols = self ._to_powerfx_symbols ()
562+ # Use setlocale(category) query form so we can restore the exact prior value.
563+ # getlocale() returns a normalized tuple and is not always a lossless
564+ # round-trip for setlocale across platforms/locales.
565+ original_numeric_locale = locale .setlocale (locale .LC_NUMERIC )
539566 try :
540- CultureInfo .CurrentCulture = CultureInfo (_POWERFX_EVAL_LOCALE ) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
541- return engine .eval (formula , symbols = symbols , locale = _POWERFX_EVAL_LOCALE )
567+ for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES :
568+ try :
569+ locale .setlocale (locale .LC_NUMERIC , locale_candidate )
570+ break
571+ except locale .Error :
572+ continue
573+
574+ engine = Engine ()
575+ try :
576+ from System .Globalization import ( # pyright: ignore[reportMissingImports]
577+ CultureInfo , # pyright: ignore[reportUnknownVariableType]
578+ )
579+ except ImportError :
580+ return engine .eval (formula , symbols = symbols , locale = _POWERFX_EVAL_LOCALE )
581+
582+ original_culture = cast (Any , CultureInfo .CurrentCulture ) # pyright: ignore[reportUnknownMemberType]
583+ try :
584+ CultureInfo .CurrentCulture = CultureInfo (_POWERFX_EVAL_LOCALE ) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
585+ return engine .eval (formula , symbols = symbols , locale = _POWERFX_EVAL_LOCALE )
586+ finally :
587+ CultureInfo .CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
588+ except ValueError as e :
589+ error_msg = str (e )
590+ # Handle undefined variable errors gracefully by returning None
591+ # This matches the behavior of the legacy fallback parser
592+ if "isn't recognized" in error_msg or "Name isn't valid" in error_msg :
593+ logger .debug (f"PowerFx: undefined variable in expression '{ formula } ', returning None" )
594+ return None
595+ raise
542596 finally :
543- CultureInfo .CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
544- except ValueError as e :
545- error_msg = str (e )
546- # Handle undefined variable errors gracefully by returning None
547- # This matches the behavior of the legacy fallback parser
548- if "isn't recognized" in error_msg or "Name isn't valid" in error_msg :
549- logger .debug (f"PowerFx: undefined variable in expression '{ formula } ', returning None" )
550- return None
551- raise
597+ locale .setlocale (locale .LC_NUMERIC , original_numeric_locale )
552598 finally :
553- locale .setlocale (locale .LC_NUMERIC , original_numeric_locale )
599+ # Restore each temporary key to its prior value (or remove it).
600+ for path , previous in reversed (temp_writes ):
601+ if previous is self ._MISSING :
602+ self ._clear_local_path (path .removeprefix ("Local." ))
603+ else :
604+ self .set (path , previous )
554605
555606 def _eval_custom_function (self , formula : str ) -> Any | None :
556607 """Handle custom functions not supported by the Python PowerFx library.
@@ -609,7 +660,7 @@ def _eval_custom_function(self, formula: str) -> Any | None:
609660
610661 return None
611662
612- def _preprocess_custom_functions (self , formula : str ) -> str :
663+ def _preprocess_custom_functions (self , formula : str , temp_writes : list [ tuple [ str , Any ]] ) -> str :
613664 """Pre-process custom functions nested inside other PowerFx functions.
614665
615666 Custom functions like MessageText() are not supported by the PowerFx engine.
@@ -624,9 +675,14 @@ def _preprocess_custom_functions(self, formula: str) -> str:
624675
625676 Args:
626677 formula: The PowerFx formula to pre-process
678+ temp_writes: Caller-owned list. Each write to a temporary key
679+ appends a ``(path, previous_value)`` entry where
680+ ``previous_value`` is the value at ``path`` before the write
681+ or :attr:`_MISSING` if none. The caller must restore every
682+ entry, including when this method raises mid-write.
627683
628684 Returns:
629- The formula with custom function calls replaced by their evaluated results
685+ The rewritten formula.
630686 """
631687 import re
632688
@@ -635,7 +691,6 @@ def _preprocess_custom_functions(self, formula: str) -> str:
635691 # We use 500 to leave room for the rest of the expression around the replaced value.
636692 MAX_INLINE_LENGTH = 500
637693
638- # Counter for generating unique temp variable names
639694 temp_var_counter = 0
640695
641696 # Custom functions that need pre-processing: (regex pattern, handler)
@@ -691,11 +746,14 @@ def _preprocess_custom_functions(self, formula: str) -> str:
691746 # Replace in formula
692747 if isinstance (replacement , str ):
693748 if len (replacement ) > MAX_INLINE_LENGTH :
694- # Store long strings in a temp variable to avoid PowerFx expression limit
749+ # Store long results in an underscore-prefixed temp key;
750+ # record the prior value so eval() can restore it.
695751 temp_var_name = f"_TempMessageText{ temp_var_counter } "
696752 temp_var_counter += 1
697- self .set (f"Local.{ temp_var_name } " , replacement )
698- replacement_str = f"Local.{ temp_var_name } "
753+ temp_var_path = f"Local.{ temp_var_name } "
754+ temp_writes .append ((temp_var_path , self .get (temp_var_path , default = self ._MISSING )))
755+ self .set (temp_var_path , replacement )
756+ replacement_str = temp_var_path
699757 logger .debug (
700758 f"Stored long MessageText result ({ len (replacement )} chars) "
701759 f"in temp variable { temp_var_name } "
@@ -847,11 +905,13 @@ def eval_if_expression(self, value: Any) -> Any:
847905 return value
848906
849907 def interpolate_string (self , text : str ) -> str :
850- """Interpolate {Variable.Path} references in a string.
908+ """Interpolate `` {Variable.Path}`` references in a string.
851909
852- This handles template-style variable substitution like:
853- - "Created ticket #{Local.TicketParameters.TicketId}"
854- - "Routing to {Local.RoutingParameters.TeamName}"
910+ Captures brace-delimited tokens whose root segment is an identifier
911+ (``[A-Za-z][A-Za-z0-9_]*``) followed by zero or more ``.`` separated
912+ dict-key segments. Resolution is delegated to :meth:`get`; unresolved
913+ tokens are replaced with the empty string. Tokens that do not look
914+ like state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
855915
856916 Args:
857917 text: Text that may contain {Variable.Path} references
@@ -866,10 +926,11 @@ def replace_var(match: re.Match[str]) -> str:
866926 value = self .get (var_path )
867927 return str (value ) if value is not None else ""
868928
869- # Match {Variable.Path} patterns
870- pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}"
929+ # Root segment must be an identifier; follow-on segments accept any
930+ # non-empty dict-key (e.g. ``_id``, ``1``, UUIDs). ``get()`` enforces
931+ # per-segment safety on attribute traversal.
932+ pattern = r"\{([A-Za-z][A-Za-z0-9_]*(?:\.[^{}\s.]+)*)\}"
871933
872- # Replace all matches
873934 result = text
874935 for match in re .finditer (pattern , text ):
875936 replacement = replace_var (match )
0 commit comments