From 905ba990e63cbcaa48cd8dc66d3b91d1e06e57c8 Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Wed, 29 Jul 2026 21:36:01 +0530 Subject: [PATCH 1/5] fix(sandbox): silence initial import policy warnings --- temporalio/worker/workflow_sandbox/_runner.py | 21 ++++++++++++------- tests/worker/workflow_sandbox/test_runner.py | 19 +++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/temporalio/worker/workflow_sandbox/_runner.py b/temporalio/worker/workflow_sandbox/_runner.py index 7f06bfcd6..0684d30c3 100644 --- a/temporalio/worker/workflow_sandbox/_runner.py +++ b/temporalio/worker/workflow_sandbox/_runner.py @@ -136,13 +136,20 @@ def _create_instance(self) -> None: module_name = "__temporal_main__" try: # Import user code - self._run_code( - "with __temporal_importer.applied():\n" - # Import the workflow code - f" from {module_name} import {self.instance_details.defn.cls.__name__} as __temporal_workflow_class\n" - f" from {self.runner_class.__module__} import {self.runner_class.__name__} as __temporal_runner_class\n", - __temporal_importer=self.importer, - ) + # Initial workflow loading necessarily imports the workflow module + # into the sandbox. It and its import-time dependencies are not + # accidental passthroughs, so defer notification policy until the + # workflow has finished loading. + with temporalio.workflow.unsafe.sandbox_import_notification_policy( + temporalio.workflow.SandboxImportNotificationPolicy.SILENT + ): + self._run_code( + "with __temporal_importer.applied():\n" + # Import the workflow code + f" from {module_name} import {self.instance_details.defn.cls.__name__} as __temporal_workflow_class\n" + f" from {self.runner_class.__module__} import {self.runner_class.__name__} as __temporal_runner_class\n", + __temporal_importer=self.importer, + ) # Set context as in runtime self.importer.restriction_context.is_runtime = True diff --git a/tests/worker/workflow_sandbox/test_runner.py b/tests/worker/workflow_sandbox/test_runner.py index 288da0861..8b9ce3a5a 100644 --- a/tests/worker/workflow_sandbox/test_runner.py +++ b/tests/worker/workflow_sandbox/test_runner.py @@ -525,6 +525,25 @@ async def run(self) -> None: ) from err +async def test_workflow_sandbox_initial_workflow_import_does_not_warn() -> None: + restrictions = dataclasses.replace( + SandboxRestrictions.default, + import_notification_policy=SandboxImportNotificationPolicy.WARN_ON_UNINTENTIONAL_PASSTHROUGH, + ) + + with warnings.catch_warnings(record=True) as recorder: + warnings.simplefilter("always") + SandboxedWorkflowRunner(restrictions).prepare_workflow( + workflow._Definition.from_class(LazyImportWorkflow) + ) + + assert ( + "Module tests.worker.workflow_sandbox.test_runner was not intentionally " + "passed through to the sandbox." + not in {str(warning.message) for warning in recorder} + ) + + async def test_workflow_sandbox_import_default_warnings(client: Client): restrictions = dataclasses.replace( SandboxRestrictions.default, From 6dbeeef020365905931d76c777d6a084da08aad8 Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Wed, 29 Jul 2026 22:29:59 +0530 Subject: [PATCH 2/5] test(sandbox): narrow initial import warning regression --- tests/worker/workflow_sandbox/test_runner.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/worker/workflow_sandbox/test_runner.py b/tests/worker/workflow_sandbox/test_runner.py index 8b9ce3a5a..d9552039e 100644 --- a/tests/worker/workflow_sandbox/test_runner.py +++ b/tests/worker/workflow_sandbox/test_runner.py @@ -533,9 +533,9 @@ async def test_workflow_sandbox_initial_workflow_import_does_not_warn() -> None: with warnings.catch_warnings(record=True) as recorder: warnings.simplefilter("always") - SandboxedWorkflowRunner(restrictions).prepare_workflow( - workflow._Definition.from_class(LazyImportWorkflow) - ) + definition = workflow._Definition.from_class(LazyImportWorkflow) + assert definition is not None + SandboxedWorkflowRunner(restrictions).prepare_workflow(definition) assert ( "Module tests.worker.workflow_sandbox.test_runner was not intentionally " From fb1b110f883b94e9cd40c900fbdaf672743b286e Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Thu, 30 Jul 2026 02:33:10 +0530 Subject: [PATCH 3/5] fix(sandbox): retain initial dependency import notifications --- .../worker/workflow_sandbox/_importer.py | 17 ++++++++++ temporalio/worker/workflow_sandbox/_runner.py | 12 +++---- tests/worker/workflow_sandbox/test_runner.py | 34 +++++++++++++++++++ .../initial_import_warning_dependency.py | 10 ++++++ 4 files changed, 65 insertions(+), 8 deletions(-) create mode 100644 tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py diff --git a/temporalio/worker/workflow_sandbox/_importer.py b/temporalio/worker/workflow_sandbox/_importer.py index 1ab0a1dd6..3cbe7d237 100644 --- a/temporalio/worker/workflow_sandbox/_importer.py +++ b/temporalio/worker/workflow_sandbox/_importer.py @@ -66,6 +66,7 @@ def __init__( "__main__": types.ModuleType("__main__"), } self.modules_checked_for_restrictions: set[str] = set() + self._initial_workflow_module_import: str | None = None self.import_func = self._import if not LOG_TRACE else self._traced_import # Pre-collect restricted builtins self.restricted_builtins: list[tuple[str, _ThreadLocalCallable, Callable]] = [] @@ -159,6 +160,16 @@ def applied(self) -> Iterator[None]: finally: Importer._thread_local_current.importer = orig_importer + @contextmanager + def initial_workflow_module_import(self, module_name: str) -> Iterator[None]: + """Suppress notifications only for the initial workflow module import.""" + previous_module = self._initial_workflow_module_import + self._initial_workflow_module_import = module_name + try: + yield None + finally: + self._initial_workflow_module_import = previous_module + @contextmanager def _unapplied(self) -> Iterator[None]: orig_importer = Importer.current_importer() @@ -307,6 +318,12 @@ def _is_import_notification_policy_applied( return policy in self.restrictions.import_notification_policy def _maybe_passthrough_module(self, name: str) -> types.ModuleType | None: + # The workflow module itself must be loaded into every sandbox instance. + # That import is intentional, but its import-time dependencies still need + # to follow the configured notification policy. + if name == self._initial_workflow_module_import: + return None + # If imports not passed through and all modules are not passed through # and name not in passthrough modules, check parents if ( diff --git a/temporalio/worker/workflow_sandbox/_runner.py b/temporalio/worker/workflow_sandbox/_runner.py index 0684d30c3..98c442018 100644 --- a/temporalio/worker/workflow_sandbox/_runner.py +++ b/temporalio/worker/workflow_sandbox/_runner.py @@ -135,14 +135,10 @@ def _create_instance(self) -> None: if module_name == "__main__": module_name = "__temporal_main__" try: - # Import user code - # Initial workflow loading necessarily imports the workflow module - # into the sandbox. It and its import-time dependencies are not - # accidental passthroughs, so defer notification policy until the - # workflow has finished loading. - with temporalio.workflow.unsafe.sandbox_import_notification_policy( - temporalio.workflow.SandboxImportNotificationPolicy.SILENT - ): + # Import user code. The workflow module is intentionally loaded + # into every sandbox instance, while its import-time dependencies + # must retain the configured warning/error policy. + with self.importer.initial_workflow_module_import(module_name): self._run_code( "with __temporal_importer.applied():\n" # Import the workflow code diff --git a/tests/worker/workflow_sandbox/test_runner.py b/tests/worker/workflow_sandbox/test_runner.py index d9552039e..f1e97bddb 100644 --- a/tests/worker/workflow_sandbox/test_runner.py +++ b/tests/worker/workflow_sandbox/test_runner.py @@ -544,6 +544,40 @@ async def test_workflow_sandbox_initial_workflow_import_does_not_warn() -> None: ) +async def test_workflow_sandbox_initial_workflow_import_warns_for_dependencies() -> ( + None +): + from tests.worker.workflow_sandbox.testmodules.initial_import_warning_dependency import ( + InitialImportWarningDependencyWorkflow, + ) + + restrictions = dataclasses.replace( + SandboxRestrictions.default, + import_notification_policy=SandboxImportNotificationPolicy.WARN_ON_UNINTENTIONAL_PASSTHROUGH, + ) + + with warnings.catch_warnings(record=True) as recorder: + warnings.simplefilter("always") + definition = workflow._Definition.from_class( + InitialImportWarningDependencyWorkflow + ) + assert definition is not None + SandboxedWorkflowRunner(restrictions).prepare_workflow(definition) + + assert ( + "Module tests.worker.workflow_sandbox.testmodules.lazy_module was not " + "intentionally passed through to the sandbox." + in {str(warning.message) for warning in recorder} + ) + + raising_restrictions = dataclasses.replace( + restrictions, + import_notification_policy=SandboxImportNotificationPolicy.RAISE_ON_UNINTENTIONAL_PASSTHROUGH, + ) + with pytest.raises(UnintentionalPassthroughError, match="lazy_module"): + SandboxedWorkflowRunner(raising_restrictions).prepare_workflow(definition) + + async def test_workflow_sandbox_import_default_warnings(client: Client): restrictions = dataclasses.replace( SandboxRestrictions.default, diff --git a/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py b/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py new file mode 100644 index 000000000..2ead12a62 --- /dev/null +++ b/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py @@ -0,0 +1,10 @@ +from temporalio import workflow + +import tests.worker.workflow_sandbox.testmodules.lazy_module # noqa: F401 + + +@workflow.defn +class InitialImportWarningDependencyWorkflow: + @workflow.run + async def run(self) -> None: + pass From de6e0554355e46df51b9a59d1c34ce30fd907dd6 Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Thu, 30 Jul 2026 03:12:07 +0530 Subject: [PATCH 4/5] fix(sandbox): scope initial import suppression --- CHANGELOG.md | 3 +++ temporalio/worker/workflow_sandbox/_importer.py | 17 +++++++++++------ .../initial_import_warning_dependency.py | 3 +-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8fe94d4b..62426485b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,9 @@ to include examples, links to docs, or any other relevant information. - Marked system Nexus envelope payloads so nested payloads can be detected and visited after the envelope is already stored as a payload. +- Suppressed intentional passthrough notifications for the initial workflow + module import while retaining configured notifications for that module's + import-time dependencies. ### Security diff --git a/temporalio/worker/workflow_sandbox/_importer.py b/temporalio/worker/workflow_sandbox/_importer.py index 3cbe7d237..78c52615d 100644 --- a/temporalio/worker/workflow_sandbox/_importer.py +++ b/temporalio/worker/workflow_sandbox/_importer.py @@ -321,8 +321,7 @@ def _maybe_passthrough_module(self, name: str) -> types.ModuleType | None: # The workflow module itself must be loaded into every sandbox instance. # That import is intentional, but its import-time dependencies still need # to follow the configured notification policy. - if name == self._initial_workflow_module_import: - return None + is_initial_workflow_module = name == self._initial_workflow_module_import # If imports not passed through and all modules are not passed through # and name not in passthrough modules, check parents @@ -330,13 +329,19 @@ def _maybe_passthrough_module(self, name: str) -> types.ModuleType | None: not temporalio.workflow.unsafe.is_imports_passed_through() and not self.module_configured_passthrough(name) ): - if self._is_import_notification_policy_applied( - temporalio.workflow.SandboxImportNotificationPolicy.RAISE_ON_UNINTENTIONAL_PASSTHROUGH + if ( + not is_initial_workflow_module + and self._is_import_notification_policy_applied( + temporalio.workflow.SandboxImportNotificationPolicy.RAISE_ON_UNINTENTIONAL_PASSTHROUGH + ) ): raise UnintentionalPassthroughError(name) - if self._is_import_notification_policy_applied( - temporalio.workflow.SandboxImportNotificationPolicy.WARN_ON_UNINTENTIONAL_PASSTHROUGH + if ( + not is_initial_workflow_module + and self._is_import_notification_policy_applied( + temporalio.workflow.SandboxImportNotificationPolicy.WARN_ON_UNINTENTIONAL_PASSTHROUGH + ) ): warnings.warn( f"Module {name} was not intentionally passed through to the sandbox." diff --git a/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py b/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py index 2ead12a62..bcc28753c 100644 --- a/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py +++ b/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py @@ -1,6 +1,5 @@ -from temporalio import workflow - import tests.worker.workflow_sandbox.testmodules.lazy_module # noqa: F401 +from temporalio import workflow @workflow.defn From 7671d84088102df3ba427417be71e39059b65f92 Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Sat, 1 Aug 2026 00:09:40 +0530 Subject: [PATCH 5/5] test(sandbox): silence fixture type warning --- .../testmodules/initial_import_warning_dependency.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py b/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py index bcc28753c..c2f782540 100644 --- a/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py +++ b/tests/worker/workflow_sandbox/testmodules/initial_import_warning_dependency.py @@ -1,4 +1,4 @@ -import tests.worker.workflow_sandbox.testmodules.lazy_module # noqa: F401 +import tests.worker.workflow_sandbox.testmodules.lazy_module # type:ignore[reportUnusedImport] # noqa: F401 from temporalio import workflow