Skip to content

Commit b6f95e2

Browse files
authored
Python: Add class validation for Dapr Runtime step loading (#13499)
### Motivation and Context The Dapr Runtime uses string-based class names to load step classes dynamically. This PR adds validation to ensure that only valid KernelProcessStep subclasses can be loaded and instantiated, improving type safety and providing better error messages when misconfigured. The new allowed_module_prefixes parameter gives users control over which modules are permitted for step class loading, which can be useful in environments where stricter control is desired. <!-- Thank you for your contribution to the semantic-kernel repo! Please help reviewers and future users, providing the following information: 1. Why is this change required? 2. What problem does it solve? 3. What scenario does it contribute to? 4. If it fixes an open issue, please link to the issue here. --> ### Description - Add issubclass(cls, KernelProcessStep) validation when loading step classes from qualified names - Add optional allowed_module_prefixes parameter for restricting which modules can be loaded - Centralize class loading logic in get_step_class_from_qualified_name() utility function - Remove duplicate _get_class_from_string methods from DaprStepInfo and StepActor <!-- Describe your changes, the overall approach, the underlying design. These notes will help understanding how your code works. Thanks! --> ### Contribution Checklist <!-- Before submitting this PR, please make sure: --> - [X] The code builds clean without any errors or warnings - [X] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [X] All unit tests pass, and I have added new tests where possible - [ ] I didn't break anyone 😄
1 parent cbb1f21 commit b6f95e2

File tree

6 files changed

+311
-32
lines changed

6 files changed

+311
-32
lines changed

python/semantic_kernel/processes/dapr_runtime/actors/step_actor.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
import asyncio
4-
import importlib
54
import json
65
import logging
7-
from collections.abc import Callable
6+
from collections.abc import Callable, Sequence
87
from inspect import isawaitable
98
from queue import Queue
109
from typing import Any
@@ -38,7 +37,11 @@
3837
from semantic_kernel.processes.process_message import ProcessMessage
3938
from semantic_kernel.processes.process_message_factory import ProcessMessageFactory
4039
from semantic_kernel.processes.process_types import get_generic_state_type
41-
from semantic_kernel.processes.step_utils import find_input_channels, get_fully_qualified_name
40+
from semantic_kernel.processes.step_utils import (
41+
find_input_channels,
42+
get_fully_qualified_name,
43+
get_step_class_from_qualified_name,
44+
)
4245
from semantic_kernel.utils.feature_stage_decorator import experimental
4346

4447
logger: logging.Logger = logging.getLogger(__name__)
@@ -48,18 +51,29 @@
4851
class StepActor(Actor, StepInterface, KernelProcessMessageChannel):
4952
"""Represents a step actor that follows the Step abstract class."""
5053

51-
def __init__(self, ctx: ActorRuntimeContext, actor_id: ActorId, kernel: Kernel, factories: dict[str, Callable]):
54+
def __init__(
55+
self,
56+
ctx: ActorRuntimeContext,
57+
actor_id: ActorId,
58+
kernel: Kernel,
59+
factories: dict[str, Callable],
60+
allowed_module_prefixes: Sequence[str] | None = None,
61+
):
5262
"""Initializes a new instance of StepActor.
5363
5464
Args:
5565
ctx: The actor runtime context.
5666
actor_id: The unique ID for the actor.
5767
kernel: The Kernel dependency to be injected.
5868
factories: The factory dictionary to use for creating the step.
69+
allowed_module_prefixes: Optional sequence of module prefixes that are allowed
70+
for step class loading. If provided, step classes must come from modules
71+
starting with one of these prefixes.
5972
"""
6073
super().__init__(ctx, actor_id)
6174
self.kernel = kernel
6275
self.factories: dict[str, Callable] = factories
76+
self.allowed_module_prefixes: Sequence[str] | None = allowed_module_prefixes
6377
self.parent_process_id: str | None = None
6478
self.step_info: DaprStepInfo | None = None
6579
self.initialize_task: bool | None = False
@@ -168,12 +182,6 @@ async def process_incoming_messages(self):
168182
await self._state_manager.try_add_state(ActorStateKeys.StepIncomingMessagesState.value, messages_to_save)
169183
await self._state_manager.save_state()
170184

171-
def _get_class_from_string(self, full_class_name: str):
172-
"""Gets a class from a string."""
173-
module_name, class_name = full_class_name.rsplit(".", 1)
174-
module = importlib.import_module(module_name)
175-
return getattr(module, class_name)
176-
177185
async def activate_step(self):
178186
"""Initializes the step."""
179187
# Instantiate an instance of the inner step object and retrieve its class reference.
@@ -184,7 +192,10 @@ async def activate_step(self):
184192
step_cls = step_object.__class__
185193
step_instance: KernelProcessStep = step_object # type: ignore
186194
else:
187-
step_cls = self._get_class_from_string(self.inner_step_type)
195+
step_cls = get_step_class_from_qualified_name(
196+
self.inner_step_type,
197+
allowed_module_prefixes=self.allowed_module_prefixes,
198+
)
188199
step_instance: KernelProcessStep = step_cls() # type: ignore
189200

190201
kernel_plugin = self.kernel.add_plugin(

python/semantic_kernel/processes/dapr_runtime/dapr_process_info.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33

4-
from collections.abc import MutableSequence
4+
from collections.abc import MutableSequence, Sequence
55
from typing import Literal
66

77
from pydantic import Field
@@ -20,17 +20,23 @@ class DaprProcessInfo(DaprStepInfo):
2020
type: Literal["DaprProcessInfo"] = "DaprProcessInfo" # type: ignore
2121
steps: MutableSequence["DaprStepInfo | DaprProcessInfo"] = Field(default_factory=list)
2222

23-
def to_kernel_process(self) -> KernelProcess:
24-
"""Converts the Dapr process info to a kernel process."""
23+
def to_kernel_process(self, allowed_module_prefixes: Sequence[str] | None = None) -> KernelProcess:
24+
"""Converts the Dapr process info to a kernel process.
25+
26+
Args:
27+
allowed_module_prefixes: Optional list of module prefixes that are allowed
28+
for step class loading. If provided, step classes must come from modules
29+
starting with one of these prefixes.
30+
"""
2531
if not isinstance(self.state, KernelProcessState):
2632
raise ValueError("State must be a kernel process state")
2733

2834
steps: list[KernelProcessStepInfo] = []
2935
for step in self.steps:
3036
if isinstance(step, DaprProcessInfo):
31-
steps.append(step.to_kernel_process())
37+
steps.append(step.to_kernel_process(allowed_module_prefixes=allowed_module_prefixes))
3238
else:
33-
steps.append(step.to_kernel_process_step_info())
39+
steps.append(step.to_kernel_process_step_info(allowed_module_prefixes=allowed_module_prefixes))
3440

3541
return KernelProcess(state=self.state, steps=steps, edges=self.edges)
3642

python/semantic_kernel/processes/dapr_runtime/dapr_step_info.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
import importlib
3+
from collections.abc import Sequence
44
from typing import Literal
55

66
from pydantic import Field
@@ -11,7 +11,7 @@
1111
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
1212
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
1313
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
14-
from semantic_kernel.processes.step_utils import get_fully_qualified_name
14+
from semantic_kernel.processes.step_utils import get_fully_qualified_name, get_step_class_from_qualified_name
1515
from semantic_kernel.utils.feature_stage_decorator import experimental
1616

1717

@@ -24,13 +24,20 @@ class DaprStepInfo(KernelBaseModel):
2424
state: KernelProcessState | KernelProcessStepState
2525
edges: dict[str, list[KernelProcessEdge]] = Field(default_factory=dict)
2626

27-
def to_kernel_process_step_info(self) -> KernelProcessStepInfo:
28-
"""Converts the Dapr step info to a kernel process step info."""
29-
inner_step_type = self._get_class_from_string(self.inner_step_python_type)
30-
if inner_step_type is None:
31-
raise KernelException(
32-
f"Unable to create inner step type from assembly qualified name `{self.inner_step_python_type}`"
33-
)
27+
def to_kernel_process_step_info(
28+
self, allowed_module_prefixes: Sequence[str] | None = None
29+
) -> KernelProcessStepInfo:
30+
"""Converts the Dapr step info to a kernel process step info.
31+
32+
Args:
33+
allowed_module_prefixes: Optional list of module prefixes that are allowed
34+
for step class loading. If provided, step classes must come from modules
35+
starting with one of these prefixes.
36+
"""
37+
inner_step_type = get_step_class_from_qualified_name(
38+
self.inner_step_python_type,
39+
allowed_module_prefixes=allowed_module_prefixes,
40+
)
3441
return KernelProcessStepInfo(inner_step_type=inner_step_type, state=self.state, output_edges=self.edges)
3542

3643
@classmethod
@@ -46,9 +53,3 @@ def from_kernel_step_info(cls, kernel_step_info: KernelProcessStepInfo) -> "Dapr
4653
state=kernel_step_info.state,
4754
edges={key: list(value) for key, value in kernel_step_info.edges.items()},
4855
)
49-
50-
def _get_class_from_string(self, full_class_name: str):
51-
"""Gets a class from a string."""
52-
module_name, class_name = full_class_name.rsplit(".", 1)
53-
module = importlib.import_module(module_name)
54-
return getattr(module, class_name)

python/semantic_kernel/processes/step_utils.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3+
import importlib
4+
import inspect
5+
from collections.abc import Sequence
36
from typing import Any
47

8+
from semantic_kernel.exceptions.process_exceptions import ProcessInvalidConfigurationException
59
from semantic_kernel.functions.kernel_function import KernelFunction
610
from semantic_kernel.processes.kernel_process.kernel_process_message_channel import KernelProcessMessageChannel
11+
from semantic_kernel.processes.kernel_process.kernel_process_step import KernelProcessStep
712
from semantic_kernel.processes.kernel_process.kernel_process_step_context import KernelProcessStepContext
813
from semantic_kernel.utils.feature_stage_decorator import experimental
914

@@ -37,3 +42,80 @@ def find_input_channels(
3742
def get_fully_qualified_name(cls) -> str:
3843
"""Gets the fully qualified name of a class."""
3944
return f"{cls.__module__}.{cls.__name__}"
45+
46+
47+
@experimental
48+
def get_step_class_from_qualified_name(
49+
full_class_name: str,
50+
allowed_module_prefixes: Sequence[str] | None = None,
51+
) -> type[KernelProcessStep]:
52+
"""Loads and validates a KernelProcessStep class from a fully qualified name.
53+
54+
This function validates that the loaded class is a proper subclass of
55+
KernelProcessStep, preventing instantiation of arbitrary classes.
56+
57+
Args:
58+
full_class_name: The fully qualified class name in Python import notation
59+
(e.g., 'mypackage.mymodule.MyStep'). The module must be importable
60+
from the current Python environment.
61+
allowed_module_prefixes: Optional list of module prefixes that are allowed
62+
to be imported. If provided, the module must start with one of these
63+
prefixes. This check is performed BEFORE import to prevent execution
64+
of module-level code in unauthorized modules. If None or empty, any
65+
module is allowed.
66+
67+
Returns:
68+
The validated class type that is a subclass of KernelProcessStep
69+
70+
Raises:
71+
ProcessInvalidConfigurationException: Raised when:
72+
- The class name format is invalid (missing module separator)
73+
- The module is not in the allowed prefixes list (if provided)
74+
- The module cannot be imported
75+
- The class attribute doesn't exist in the module
76+
- The attribute is not a class type
77+
- The class is not a subclass of KernelProcessStep
78+
"""
79+
if not full_class_name or "." not in full_class_name:
80+
raise ProcessInvalidConfigurationException(
81+
f"Invalid step class name format: '{full_class_name}'. "
82+
"Expected a fully qualified name like 'module.ClassName'."
83+
)
84+
85+
module_name, class_name = full_class_name.rsplit(".", 1)
86+
87+
if not module_name or not class_name:
88+
raise ProcessInvalidConfigurationException(
89+
f"Invalid step class name format: '{full_class_name}'. Module name and class name cannot be empty."
90+
)
91+
92+
# Check module allowlist BEFORE import to prevent module-level code execution
93+
if allowed_module_prefixes and not any(module_name.startswith(prefix) for prefix in allowed_module_prefixes):
94+
raise ProcessInvalidConfigurationException(
95+
f"Module '{module_name}' is not in the allowed module prefixes: {allowed_module_prefixes}. "
96+
f"Step class '{full_class_name}' cannot be loaded."
97+
)
98+
99+
try:
100+
module = importlib.import_module(module_name)
101+
except ImportError as e:
102+
raise ProcessInvalidConfigurationException(
103+
f"Unable to import module '{module_name}' for step class '{full_class_name}': {e}"
104+
) from e
105+
106+
try:
107+
cls = getattr(module, class_name)
108+
except AttributeError as e:
109+
raise ProcessInvalidConfigurationException(
110+
f"Class '{class_name}' not found in module '{module_name}': {e}"
111+
) from e
112+
113+
if not inspect.isclass(cls):
114+
raise ProcessInvalidConfigurationException(f"'{full_class_name}' is not a class type, got {type(cls).__name__}")
115+
116+
if not issubclass(cls, KernelProcessStep):
117+
raise ProcessInvalidConfigurationException(
118+
f"Step class '{full_class_name}' must be a subclass of KernelProcessStep. Got: {cls.__bases__}"
119+
)
120+
121+
return cls

python/tests/unit/processes/dapr_runtime/test_dapr_kernel_process_context.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111
from semantic_kernel.processes.kernel_process.kernel_process import KernelProcess
1212
from semantic_kernel.processes.kernel_process.kernel_process_event import KernelProcessEvent
1313
from semantic_kernel.processes.kernel_process.kernel_process_state import KernelProcessState
14+
from semantic_kernel.processes.kernel_process.kernel_process_step import KernelProcessStep
1415
from semantic_kernel.processes.kernel_process.kernel_process_step_info import KernelProcessStepInfo
1516
from semantic_kernel.processes.kernel_process.kernel_process_step_state import KernelProcessStepState
1617

1718

18-
class DummyInnerStepType:
19+
class DummyInnerStepType(KernelProcessStep):
20+
"""A valid KernelProcessStep subclass for testing."""
21+
1922
pass
2023

2124

0 commit comments

Comments
 (0)