This guide covers the security sandbox that protects the Erlang VM when running embedded Python code.
When Python runs embedded inside the Erlang VM (BEAM), certain operations must be blocked because they would corrupt or destabilize the runtime. The erlang_python library automatically installs a sandbox that blocks these dangerous operations.
The Erlang VM is a sophisticated runtime with:
- Multiple scheduler threads managing lightweight processes
- Complex memory management and garbage collection
- Intricate internal state for message passing and I/O
When fork() is called, the child process gets a copy of the parent's memory but only the calling thread. This leaves the child with corrupted state - scheduler threads are missing, locks are in inconsistent states, and internal data structures are broken. The child process will crash or behave unpredictably.
Similarly, exec() replaces the current process image entirely, terminating the Erlang VM.
The sandbox uses Python's audit hook system (PEP 578) to intercept dangerous operations at a low level, before they can execute. The hook is installed automatically when erlang_python starts and cannot be removed once installed.
This provides defense-in-depth - even if Python code tries to import os or subprocess directly, the operations are blocked.
| Operation | Module | Reason |
|---|---|---|
fork() |
os |
Corrupts Erlang VM state |
forkpty() |
os |
Uses fork internally |
system() |
os |
Executes via shell (uses fork) |
popen() |
os |
Opens pipe to subprocess (uses fork) |
exec*() |
os |
Replaces process image |
spawn*() |
os |
Creates subprocess (uses fork) |
posix_spawn*() |
os |
POSIX subprocess creation |
Popen |
subprocess |
Creates subprocess (uses fork) |
run() |
subprocess |
Wrapper around Popen |
call() |
subprocess |
Wrapper around Popen |
When blocked operations are attempted, you'll see:
>>> import subprocess
>>> subprocess.run(['ls'])
RuntimeError: subprocess.Popen is blocked in Erlang VM context.
fork()/exec() would corrupt the Erlang runtime.
Use Erlang ports (open_port/2) for subprocess management.>>> import os
>>> os.fork()
RuntimeError: os.fork is blocked in Erlang VM context.
fork()/exec() would corrupt the Erlang runtime.
Use Erlang ports (open_port/2) for subprocess management.Instead of using Python's subprocess facilities, use Erlang's port mechanism which properly manages external processes.
%% Run a command and capture output
run_command(Cmd) ->
Port = open_port({spawn, Cmd}, [exit_status, binary, stderr_to_stdout]),
collect_output(Port, []).
collect_output(Port, Acc) ->
receive
{Port, {data, Data}} ->
collect_output(Port, [Data | Acc]);
{Port, {exit_status, Status}} ->
{Status, iolist_to_binary(lists:reverse(Acc))}
after 30000 ->
port_close(Port),
{error, timeout}
end.
%% Usage
{0, Output} = run_command("ls -la").Register an Erlang function that runs commands:
%% In Erlang
py:register_function(run_shell, fun([Cmd]) ->
Port = open_port({spawn, binary_to_list(Cmd)},
[exit_status, binary, stderr_to_stdout]),
collect_output(Port, [])
end).# In Python
from erlang import run_shell
# This calls through Erlang, which properly manages the subprocess
result = run_shell("ls -la")%% Start a long-running process
{ok, Port} = py:call('__main__', start_worker_via_erlang, []),
%% The Python code registers a function:
py:register_function(start_worker_via_erlang, fun([]) ->
Port = open_port({spawn, "python3 worker.py"},
[binary, {line, 1024}, use_stdio]),
Port % Return port reference to Python
end).For Python code that needs to trigger external processes, use message passing to coordinate with Erlang supervisors:
import erlang
# Send a request to an Erlang process that manages subprocesses
erlang.send(supervisor_pid, ('spawn_worker', worker_args))From Python, you can check if the sandbox is active:
from _erlang_impl._sandbox import is_sandboxed
if is_sandboxed():
print("Running inside Erlang VM - subprocess operations blocked")The audit hook keeps Python from forking the VM; it does not protect the VM from Python. A C extension can still segfault the node, and nothing can cap the memory or CPU of embedded code. For that boundary run the context in a child process:
{ok, Ctx} = py_context:new(#{mode => isolated,
rlimits => #{as => 256 * 1024 * 1024, cpu => 10},
kill_after => 1000}).A crash kills only the child, py_context:kill/1 is total, and rlimits or
cgroups bound resources. See Isolated Contexts.
That is a boundary against Python failing, not against Python reaching.
The child runs as the same user as the node, so it can read and write
whatever that user can, open any connection, spawn processes, and read the
environment the node was started with. Nothing in this library confines it,
and confining it from inside the interpreter does not work: an audit hook
never sees a C extension calling open(2), and the audit event for open
does not say which directory a relative path is resolved against, so a
check written in Python can be walked around with documented calls.
If you need to bound what Python may reach, use the mechanisms the operating system already has, around the node rather than inside it:
- a container or jail, with the filesystem and network the job should see;
- a separate user, with file permissions to match, and
py_context:new/1started from a node running as that user; - on Linux, a systemd unit with
ProtectSystem,ReadWritePathsandIPAddressDeny, which express the same intent and are enforced by the kernel.
Kernel sandboxing per context (Landlock, Seatbelt) would let this library express it directly; it is not implemented.
Signal handling is also not supported in the Erlang event loop. The ErlangEventLoop raises NotImplementedError for add_signal_handler() and remove_signal_handler(). Signal handling should be done at the Erlang VM level using Erlang's signal handling facilities.
- Getting Started - Basic usage guide
- Asyncio - Erlang-native asyncio event loop
- Threading - Python threading support