A lightweight Python code runner with guardrails for LLM agent workflows.
Recent updates are tracked in CHANGELOG.md. Detailed docs are available in docs/README.md.
safe-py-runner runs user code in a separate subprocess with timeout, memory, and policy guardrails.
It is safer than running eval or exec in your main process.
It is not a full security sandbox and should not be your only isolation boundary for hostile public code.
Honest scope:
- Good fit: LLM-generated scripts for your own team, internal tools, controlled workloads.
- Not good alone: anonymous public code execution. Pair this with Docker/VM/OS sandboxing first.
| Option | Isolation Strength | Operational Cost | Typical Use |
|---|---|---|---|
eval / exec in main process |
Low | Low | Local scripts, trusted experiments |
safe-py-runner |
Medium | Low to medium | Internal tools, agent prototypes, controlled workloads |
| Docker / VM / E2B-style sandbox | High | Medium to high | Production multi-tenant or hostile untrusted code |
Production guidance:
- For hostile public-user code, use Docker/VM/external sandboxing as the primary boundary.
safe-py-runnercan still be useful as an inner guardrail layer inside that setup.
If you want a direct comparison with Linux bubblewrap (bwrap) for import controls, memory limits, and timeouts, see:
docs/BUBBLEWRAP_COMPARISON.md
- Process isolation: user code runs in a separate subprocess.
- Timeouts: terminate scripts that run too long (default 5s).
- Memory limits: enforce RAM caps (default 256MB) on POSIX systems.
- Import and builtin policy controls: blocklist/allowlist behavior via policy modes.
- Input/output marshalling: pass JSON-safe input and collect
result,stdout, andstderr.
pip install safe-py-runnerrun_code now takes an explicit engine object:
LocalEngine: subprocess worker in a managed virtual environment.DockerEngine: worker inside a managed Docker container pool.
Example:
from safe_py_runner import DockerEngine, run_code
engine = DockerEngine(container_registry="safe-py-runner-runtime:local")
result = run_code("result = 2 + 2", engine=engine)Important behavior:
- If Docker is unavailable, DockerEngine returns a clear error (no silent fallback).
- Docker pool defaults:
pool_size=min(cpu_count, 4),max_runs=25,ttl_seconds=600. - DockerEngine exposes managed-only admin methods:
list_containers,list_images,stop_container,kill_container,cleanup_stale.
See docs/BACKENDS.md for full details. See docs/EXAMPLES.md for end-to-end examples.
You can manage safe-py-runner managed Docker resources directly:
python -m spr list containers
python -m spr list images
python -m spr container <id-or-name>
python -m spr stop container <id>
python -m spr kill container <id>
python -m spr cleanupRemote Docker targeting from CLI is supported:
python -m spr --docker-context my-remote-context list containers
python -m spr --ssh-host server --ssh-user ubuntu --ssh-port 22 list containersNote: CLI commands are managed-only. They do not operate on unrelated Docker resources.
from functools import partial
from safe_py_runner import LocalEngine, RunnerPolicy, run_code
# Define a policy (optional, defaults are already restrictive)
policy = RunnerPolicy(
timeout_seconds=5,
memory_limit_mb=128,
blocked_imports=["os", "subprocess", "socket"],
)
# Create engine once (required)
engine = LocalEngine(venv_dir="/tmp/safe_py_runner_demo_venv", venv_manager="uv")
# Optional convenience: bind engine for repeated calls
run_with_engine = partial(run_code, engine=engine)
# Run code
result = run_code(
code="import math\nresult = math.sqrt(input_data['x'])",
input_data={"x": 81},
policy=policy,
engine=engine,
)
if result.ok:
print(f"Result: {result.result}") # 9.0
else:
print(f"Error: {result.error}")To keep later snippets short, assume this setup:
from functools import partial
from safe_py_runner import LocalEngine, run_code as _run_code
engine = LocalEngine(venv_dir="/absolute/path/to/your/project/.venv", venv_manager="uv")
run_code = partial(_run_code, engine=engine)Important:
- Pass the full path to the virtual environment folder itself (for example
/repo/.venv), not just the parent project directory. - If
uvis not installed, usevenv_manager="python"and the engine will create the venv usingpython -m venv.
safe-py-runner supports two policy modes:
restrict(default): block only what you list inblocked_*.allow: allow only what you list inallowed_*.
Example:
policy = RunnerPolicy(
mode="allow",
allowed_imports=["math"],
allowed_builtins=["len", "range", "print"],
allowed_globals=["x"],
)Use this when you want broad Python behavior but need to block risky symbols:
policy = RunnerPolicy(
mode="restrict",
blocked_imports=["os", "subprocess", "socket"],
blocked_builtins=["eval", "exec", "open"],
)
result = run_code(
code="import math\nresult = math.factorial(5)",
policy=policy,
)Use this when you want explicit control over what code can access:
policy = RunnerPolicy(
mode="allow",
allowed_imports=["math"],
allowed_builtins=["print", "len", "range"],
allowed_globals=["x", "helper"],
extra_globals={"helper": 10},
)
result = run_code(
code="import math\nresult = math.sqrt(x) + helper",
input_data={"x": 81},
policy=policy,
)Instead of hardcoding policy values, you can load them from a TOML file:
[policy]
mode = "restrict"
timeout_seconds = 5
memory_limit_mb = 256
max_output_kb = 128
blocked_imports = ["os", "subprocess"]
blocked_builtins = ["eval", "exec", "open"]
allowed_imports = []
allowed_builtins = []
allowed_globals = []
blocked_globals = []
[policy.extra_globals]
project_name = "safe-py-runner"result = run_code(
code="result = 1 + 1",
policy_file="/absolute/path/to/policy.toml",
)You can load a config file first, then pass it as a normal policy object:
policy = RunnerPolicy.from_file("/absolute/path/to/policy.toml")
result = run_code(code="result = 1 + 1", policy=policy)You can also set config_path directly:
policy = RunnerPolicy(config_path="/absolute/path/to/policy.toml")
result = run_code(code="result = 1 + 1", policy=policy)This is useful for multi-tenant systems where each tenant has its own policy:
tenant_policy_map = {
"team-a": "/policies/team-a.toml",
"team-b": "/policies/team-b.toml",
}
tenant_id = "team-a"
result = run_code(
code="result = len(data)",
input_data={"data": [1, 2, 3]},
policy_file=tenant_policy_map[tenant_id],
)# 1) Flat input values become top-level variables (when allowed by mode).
result = run_code(code="result = x + y", input_data={"x": 3, "y": 4})
# 2) Nested data works too.
result = run_code(
code="result = customer['name'].upper()",
input_data={"customer": {"name": "ada"}},
)
# 3) Printed output is captured separately from `result`.
result = run_code(code="print('hello')\nresult = 123")
print(result.stdout) # hello
print(result.result) # 123policy = RunnerPolicy(timeout_seconds=1, memory_limit_mb=64, max_output_kb=8)
result = run_code(
code="while True:\n pass",
policy=policy,
)
print(result.ok) # False
print(result.timed_out) # Truepolicy = RunnerPolicy(max_output_kb=4) # 4KB output cap
result = run_code(code="print('a' * (10 * 1024))", policy=policy)
print(len(result.stdout) <= 4 * 1024) # Trueok_exit = run_code("import sys\nsys.exit(0)")
print(ok_exit.ok, ok_exit.exit_code) # True, 0
err_exit = run_code("import sys\nsys.exit(2)")
print(err_exit.ok, err_exit.exit_code, err_exit.error) # False, 2, "SystemExit: 2"result = run_code(code="import os")
if not result.ok:
print("error:", result.error)
print("stderr:", result.stderr)
print("exit_code:", result.exit_code)- In
allowmode, onlyallowed_*lists are enforced. - In
restrictmode, onlyblocked_*lists are enforced. - If you provide both allow and block lists, the non-active list is ignored by current behavior.
-
This is not a full sandbox. For hostile public-user code, use Docker/VM/E2B-style isolation in addition to this package.
-
allowmode can break common Python code if builtins are too strict. Example: ifprintorlenis missing fromallowed_builtins, user code can fail withNameError. -
In
allowmode, both input key injection andextra_globalsare filtered byallowed_globals. Ifxis missing inallowed_globals, code likeresult = x + 1fails even wheninput_data={"x": 1}. Ifhelperis missing inallowed_globals,extra_globals={"helper": 10}is also ignored. -
importlibis blocked intentionally in all modes. It is blocked even if you include it inallowed_imports, to reduce indirect import bypass patterns. -
Memory limits differ by engine and platform. LocalEngine relies on
RLIMIT_AS(POSIX only), which is usually stronger on Linux and may be weaker on macOS. DockerEngine also applies container memory limits, which are generally more predictable than hostRLIMIT_AS. -
policyandpolicy_fileare mutually exclusive. Pass one or the other torun_code, not both. -
engineis required.run_code(...)no longer supports implicit backend selection. -
Package installation for
LocalEngine(packages=...)andDockerEngine(packages=...)only accepts pinned specs. Usename==versionformat (for examplepandas==2.2.2), not unpinned names. -
DockerEngine does not silently fall back to local execution. If Docker CLI/daemon is unavailable, the run fails with a clear error.
run_code(...) parameters:
code: Python code string to execute.engine: required engine instance (LocalEngineorDockerEngine).input_data: optional dictionary available asinput_dataand key-injected globals.policy: optionalRunnerPolicyobject.policy_file: optional path to TOML policy config.
RunnerResult fields:
ok,result,stdout,stderr,timed_out,resource_exceeded,error,exit_code.
Use LocalEngine(venv_dir=...) to create/reuse an isolated virtual environment:
from safe_py_runner import LocalEngine, run_code
engine = LocalEngine(
venv_dir="/path/to/project/.venv",
venv_manager="uv",
packages=["pandas==2.2.2"],
)
run_code(
code="...",
engine=engine,
)For production multi-user workloads, prefer DockerEngine.
It gives stronger OS-level isolation than local subprocess execution.
Docker Desktop:
- macOS/Windows: https://www.docker.com/products/docker-desktop/
- Linux Engine docs: https://docs.docker.com/engine/install/
Remote daemon support is available through DockerEngine options:
docker_context, docker_host, ssh_host, ssh_user, ssh_port, and ssh_key_path.
This is not an OS-level sandbox. It uses Python runtime hooks and resource limits to prevent accidents and basic misuse. For hosting code from anonymous/hostile users, you MUST pair this with Docker or similar isolation.
Memory limit note: RLIMIT_AS behavior is platform-dependent. In particular, macOS may not enforce this as strictly as Linux.
Contributions are welcome! Please open an issue or PR on GitHub.
- Push to
master: runs CI tests automatically via GitHub Actions. - Push a tag like
v0.1.1: buildssdist+ wheel, creates a GitHub Release, and publishes to PyPI via Trusted Publishing.
Release title/description are read from:
.github/release/metadata.json
Trusted Publishing configuration expected by this repo:
- workflow name:
release - workflow file:
.github/workflows/release.yml - GitHub environment:
pypi
Example release_metadata.md:
# safe-py-runner {{tag}}
Release notes for {{tag}}.
- Summarize key changes here.
- Add migration notes if any.{{tag}} is replaced automatically with the pushed tag name by the release workflow.
Assuming commit_message.txt is already updated:
- Run quality checks:
make testmake test runs ruff, mypy, and pytest (in that order).
- Stage files:
make git-add- Commit:
make git-commitmake git-commit validates:
commit_message.txtexists and is non-emptycommit_message.txtis different from the latest git commit message
- Push to
master:
make git-pushOptional shortcut:
make pushNote: if you already ran atomic steps (make git-add + make git-commit), you can run either make git-push or make push. make push is smart now: when there are no local file changes, it skips commit/test steps and only pushes.
Assuming commit_message.txt and release_metadata.md are updated:
- Bump version:
make set-version 0.1.2- Regenerate release metadata JSON:
make metadata- Run quality checks:
make test- Stage files:
make git-add- Commit:
make git-commit- Push to
master:
make git-push- Create and push release tag:
make git-tagmake git-tag reads version from pyproject.toml and will fail if:
- a version argument is provided
- version in
pyproject.tomlis missing or invalid - current tag from
pyproject.tomlis already the latest tag
Optional shortcut:
make releaseNote: if you run release as atomic steps and already executed make git-commit, continue with make git-push and make git-tag (or run make push first; it will detect no local file changes and only push).
- Run
make helpto see available commands. - Verify GitHub Actions CI passed on
masterbefore tagging. - After tagging, verify the release workflow succeeded and wheel artifact is attached.