Skip to content

Commit b1c0197

Browse files
committed
workflow: support specifying passthrough_modules to SandboxPolicy
1 parent c17ed9e commit b1c0197

3 files changed

Lines changed: 62 additions & 3 deletions

File tree

src/vercel/_internal/workflow/py_sandbox.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@
4242
contextvars.ContextVar("_sandbox_sys_modules", default=None)
4343
)
4444

45+
# Extra passthrough modules for the active sandbox, from
46+
# SandboxPolicy.passthrough_modules.
47+
_policy_passthroughs: contextvars.ContextVar[frozenset[str]] = contextvars.ContextVar(
48+
"_policy_passthroughs", default=frozenset()
49+
)
50+
4551

4652
class SandboxRestrictionError(RuntimeError):
4753
"""Raised when workflow code calls a non-deterministic function."""
@@ -573,9 +579,10 @@ def __init__(
573579
self._blocked = blocked or set()
574580

575581
def _is_passthrough(self, name: str) -> bool:
576-
for prefix in self._passthrough:
577-
if name == prefix or name.startswith(prefix + "."):
578-
return True
582+
for prefixes in (self._passthrough, _policy_passthroughs.get()):
583+
for prefix in prefixes:
584+
if name == prefix or name.startswith(prefix + "."):
585+
return True
579586
return False
580587

581588
def find_spec(
@@ -921,9 +928,17 @@ class SandboxPolicy:
921928
A handler that raises is logged and skipped, and never masks the
922929
workflow's own exception. Handlers must be thread-safe: other runs
923930
may be executing concurrently.
931+
932+
``passthrough_modules`` are extra modules — each name covering its
933+
submodules too — served from the host instead of re-imported per
934+
run, in addition to the built-in passthrough set. Use for large or
935+
stateful modules that are safe to share; nothing checks them for
936+
nondeterminism, and their state is shared with the host and every
937+
concurrent run.
924938
"""
925939

926940
cleanups: tuple[CleanupHandler, ...] = ()
941+
passthrough_modules: frozenset[str] = frozenset()
927942

928943

929944
# TODO: we probably want to support some form of sandbox caching
@@ -946,9 +961,11 @@ def workflow_sandbox(*, random_seed: str, policy: SandboxPolicy | None = None) -
946961
table_token = _sandbox_sys_modules.set(table)
947962
sandbox_token = _in_sandbox.set(True)
948963
random_token = _sandbox_random.set(random.Random(random_seed))
964+
passthrough_token = _policy_passthroughs.set(frozenset(policy.passthrough_modules))
949965
try:
950966
yield
951967
finally:
968+
_policy_passthroughs.reset(passthrough_token)
952969
_sandbox_random.reset(random_token)
953970
_in_sandbox.reset(sandbox_token)
954971
_sandbox_sys_modules.reset(table_token)

src/vercel/workflow/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from vercel._internal.workflow.runtime import Run, StepInfo, get_step_metadata, start
33

44
from . import sandbox
5+
from .sandbox import SandboxPolicy
6+
57
from .errors import (
68
EntityConflictError,
79
HookNotFoundError,
@@ -27,4 +29,5 @@
2729
"TooEarlyError",
2830
"WorkflowWorldError",
2931
"sandbox",
32+
"SandboxPolicy",
3033
]

tests/unit/test_py_sandbox.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,45 @@ def test_collections_counter(self):
895895
assert ns["result"] == {"a": 2, "b": 2, "c": 1}
896896

897897

898+
# ═══════════════════════════════════════════════════════════════
899+
# SandboxPolicy.passthrough_modules
900+
# ═══════════════════════════════════════════════════════════════
901+
902+
903+
class TestPolicyPassthroughModules:
904+
"""SandboxPolicy.passthrough_modules extends the built-in passthrough set."""
905+
906+
def test_extra_module_shared_from_host(self):
907+
import shlex as host_shlex
908+
909+
policy = SandboxPolicy(passthrough_modules=frozenset({"shlex"}))
910+
with workflow_sandbox(random_seed=SEED, policy=policy):
911+
import shlex
912+
913+
assert shlex is host_shlex
914+
915+
def test_covers_submodules(self):
916+
import urllib.parse as host_parse
917+
918+
policy = SandboxPolicy(passthrough_modules=frozenset({"urllib"}))
919+
with workflow_sandbox(random_seed=SEED, policy=policy):
920+
import urllib.parse
921+
922+
assert urllib.parse is host_parse
923+
924+
def test_scoped_to_policy(self):
925+
import shlex as host_shlex
926+
927+
policy = SandboxPolicy(passthrough_modules=frozenset({"shlex"}))
928+
with workflow_sandbox(random_seed=SEED, policy=policy):
929+
import shlex as inside_with_policy
930+
with workflow_sandbox(random_seed=SEED):
931+
import shlex as inside_plain
932+
933+
assert inside_with_policy is host_shlex
934+
assert inside_plain is not host_shlex
935+
936+
898937
# ═══════════════════════════════════════════════════════════════
899938
# module isolation
900939
# ═══════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)