Skip to main content

pyplaypen-sandbox

One-shot subprocess sandbox for running Python inside a container.

Not a hostile-code sandbox. Code that deliberately tries to escape is held by the container around this process. This library adds depth inside that container. For escape-resistant isolation, use gVisor, Firecracker, or a VM.

What it does

Each call to Sandbox.execute forks a fresh child, in its own process group, that:

  • runs your code with a wall-clock timeout that kills the whole process group (os.killpg), so background descendants cannot outlive the call
  • applies POSIX rlimits pre-exec: CPU seconds, address space (memory), process count, max file size, open file descriptors
  • drops from root to a dedicated non-root UID before running your code (Linux exempts root from RLIMIT_NPROC, so this matters if the parent runs as root)
  • enables PR_SET_CHILD_SUBREAPER on Linux so orphaned grandchildren still get reaped after the process group is torn down
  • optionally confines writes with Landlock (Sandbox(confine_writes=True)), limiting the call to creating, modifying and deleting files under the directories it owns. No root, no added capability, no container change
  • captures stdout/stderr bounded and SHA-256-hashed, and returns the child's final expression value as JSON. Plain Python types work by default. Anything else needs a type_projector, see "Extending it"
  • collects files written into the call's workspace as artifacts, rejecting symlinks and any path that would escape the workspace, with per-file, aggregate, and count byte limits
  • logs a structured audit record per call, naming what the current platform enforced, what it applied best-effort, and what it left unsupported (see _enforcement_map in supervisor.py)

For coding agents

For a single one-off call:

Use https://github.com/lkraider/pyplaypen-sandbox to run this Python
snippet in an isolated subprocess, capped at 5 seconds of wall time and
256 MB of memory, and return the result: <code>

To bound a command without writing any integration, prefix it: pyplaypen run -- python script.py (see "Command line"). To run one against a working tree, mount it and run the container as its owner (see "Running it"). That arrangement needs nothing done to the tree first.

For a project integration, read "Extending it" and "Lower-level building blocks" below first. Decide between execute() and run_process(). Decide whether a globals_provider or a type_projector is warranted. Decide what Limits fit the workload. Write the integration the project needs. Read the examples for what they show.

Copy-paste to a coding agent, for a project integration:

Set up a process sandbox in this project using
https://github.com/lkraider/pyplaypen-sandbox. Read the README fully
first, then decide the integration this project needs.

Install

pip install pyplaypen-sandbox

No dependencies, required or optional. See type_projector below if you need numpy/pandas, or anything else, in a return value.

Upgrading from 0.4.0

run() writes nothing to your current directory. 0.4.0 defaulted artifact_dir to ".", planted sandbox-runs/<request_id>/ there, and under root added the execute bit to that directory's mode and left it. Surviving files now sit under the system temp dir, and result["artifact_root"] names where. Pass artifact_root to get them moved to a directory you choose.

run() renamed artifact_dir to artifact_root. Context and the result key both use that name. Rename the keyword at your call sites.

Artifact paths lost the sandbox-runs/ level. Context(artifact_root=P) now moves surviving files to P/<request_id>/. Each artifacts[].path stays relative to result["artifact_root"], so join the two and read the file.

A root run_process() no longer chowns your cwd. 0.4.0 chowned it to child_uid on every root call and never restored it. Your target now writes only what child_uid may write, so prepare cwd yourself: run the container as its owner (--user $(id -u):$(id -g)), grant the uid (setfacl -R -m u:65534:rwX), or chown it. A cwd the uid cannot write is logged at WARNING and the call still runs. That check reads the mode bits of cwd alone. It stays silent when cwd carries a POSIX ACL, whatever the ACL grants, and when cwd is writable and a subdirectory below it is not.

policy_version is v3. The audit record and Sandbox.enforcement carry a new filesystem_writes entry. The ctx dict a globals_provider receives dropped artifact_root, which named a directory private to the library.

Usage

import asyncio
from pyplaypen_sandbox import run

async def main():
    result = await run("1 + 1")
    print(result)  # {"status": "ok", "return_value": 2, ...}

asyncio.run(main())

For repeated calls, construct a Sandbox once (it runs a startup self-check and owns a concurrency semaphore) and reuse it:

from pyplaypen_sandbox import Sandbox, Context, Limits

sandbox = Sandbox(max_concurrency=4)
result = await sandbox.execute(
    code, Context(artifact_root=Path("./artifacts")), Limits(wall_seconds=10),
)

Result format

{
    "status": "ok" | "error",
    "return_value": <JSON value or None>,
    "stdout": "<bounded, possibly truncated>",
    "error": None | {"type": "...", "message": "..."},
    "artifacts": [{"path": ..., "name": ..., "bytes": ..., "mime_type": ...}],
    "artifact_root": None | "<absolute directory the paths above are under>",
}

Each call runs in a fresh directory this library creates under the system temp dir. Your code is chdir'd into it, so a relative write lands there and the scan collects it. artifact_root in the result names the directory those paths are relative to, and it is None when the call kept no files.

TMPDIR chooses the device that directory is created on. Everything a call writes lands there first, up to Limits.artifact_bytes (50 MiB), and moves afterwards. /tmp is a tmpfs on many images. Limits.memory_bytes does not count tmpfs pages, so a 64 MiB /tmp gives the child ENOSPC, which arrives as runtime: OSError [Errno 28].

Passing artifact_root to run(), or Context(artifact_root=...) to execute(), moves the surviving files to <that directory>/<request_id> after the run. The library creates that directory when it is absent. It changes no permission and no ownership on a directory you already had. Deleting what a call left is yours to do.

error.type is one of: syntax, runtime, serialization, timeout, memory_limit, process_limit, open_files_limit, artifact_limit, return_limit, busy, cancelled, crash, protocol, internal, extension.

Extending it

This library has no built-in helpers and no dependency on numpy, pandas, httpx, duckdb, or anything else sandboxed code might need. To let sandboxed code call out to an HTTP client, a query engine, or anything else, give Sandbox an import path to a factory function. It runs inside the child, after the privilege drop, so an extension is bound by the same rlimits as user code. Its imports live in your module.

# yourpkg/sandbox_ext.py
def build_globals(ctx: dict) -> dict:
    import httpx  # declared by your package
    def fetch(url: str) -> str:
        return httpx.get(url, timeout=ctx["extra"]["timeout"]).text
    return {"fetch": fetch}
sandbox = Sandbox(globals_provider="yourpkg.sandbox_ext:build_globals")
context = Context(artifact_root=Path("./artifacts"), extra={"timeout": 5.0})
result = await sandbox.execute('fetch("https://example.com")', context)

ctx is {"request_id", "workspace", "extra"}. extra is whatever JSON-serializable config you passed on Context. This library never reads it. A bad provider, such as a missing module or a wrong return type, fails with error.type == "extension", which separates it from a user-code error. With self_check=True, the default, that failure is caught when Sandbox() is constructed, before the first call.

The same mechanism extends what a return value can be. _project(), the function that turns your final expression into JSON, knows only plain Python types. Anything else needs a type_projector. That is one function that takes an unsupported value and returns something projectable. The return can be plain data. It can also be another unsupported value, because _project() recurses.

# yourpkg/sandbox_ext.py
def project(value):
    if isinstance(value, YourType):
        return {"field": value.field}
    raise ValueError(f"no projection for {type(value).__name__}")
sandbox = Sandbox(type_projector="yourpkg.sandbox_ext:project")

numpy and pandas support lives in pyplaypen_sandbox.projectors. It is the shipped example of this mechanism, and it works as-is:

sandbox = Sandbox(type_projector="pyplaypen_sandbox.projectors:project_numpy_pandas")
result = await sandbox.execute("import numpy as np\nnp.array([1, 2])", context)
# {"status": "ok", "return_value": [1, 2], ...}

With no type_projector configured, returning a numpy array fails with error.type == "serialization". Every other non-plain type fails the same way.

Lower-level building blocks

The layers underneath execute() are public on purpose.

Sandbox.run_process(argv, cwd=...) runs an existing program from an argv list. A script already on disk, a command line tool, a compiled binary. It applies the same rlimits, UID drop, process-group timeout and kill, and subreaper reaping. There is no JSON protocol. You get back exit status plus bounded stdout and stderr. Use it when your code does not speak the return-value protocol and you already collect output files your own way, for example a fixed entrypoint script run per call:

result = await sandbox.run_process(
    [sys.executable, "entrypoint.py"], cwd=workspace, limits=Limits(wall_seconds=60),
)
# also valid: ["./entrypoint.sh"], ["/usr/bin/some-tool", "--flag"], ...
# {"status": "ok" | "timeout" | "busy" | "cancelled" | "internal",
#  "returncode": int | None, "timed_out": bool, "stdout": str, "stderr": str}

Pass merge_output=True to fold stderr into stdout at the OS level. The two then interleave in one stream, in emission order, for an operator log that needs stderr in the context of the stdout around it. Two separate strings cannot be reassembled into that order afterwards. stderr is then "", and the merged stream is bounded by stdout_bytes. Ordering is best-effort: only writes within PIPE_BUF are atomic, and child buffering can still reorder.

Under Sandbox(confine_writes=True) the target may write only under cwd. Pass write_paths=[...] to widen that. The call gets a private TMPDIR, removed when it returns. Set TMPDIR in env to send the target somewhere else, and put that directory in write_paths.

/dev/shm is shared by every call on the host, so no write set covers it. A confined call that opens a POSIX semaphore there gets PermissionError. multiprocessing.Pool does. A confined execute() runs with HOME pointed at its own private directory, which is where matplotlib, fontconfig and numba put their caches. Each call starts with an empty one.

cwd is yours. This library never changes its ownership or permissions, so whether the target can write there is up to the deployment. See "Running it" for the two arrangements this is built around. Under root, a cwd the child UID cannot write is reported once per call, on the logger here and on stderr from the command. The call still runs. A target that only reads needs no write.

pyplaypen_sandbox.privilege holds the functions everything else is built from. It depends on no part of this package, and not on asyncio:

from pyplaypen_sandbox.privilege import (
    apply_resource_limits, confine_writes, drop_root_privileges, landlock_abi,
)

def preexec():
    apply_resource_limits({"cpu_seconds": 5, "memory_bytes": 2**30,
                            "process_count": 16, "file_bytes": 2**26,
                            "open_files": 256})
    drop_root_privileges(uid=65534)
    confine_writes(["/srv/workdir", "/dev/null"])  # optional, and call it last

subprocess.Popen(argv, preexec_fn=preexec)  # works with plain subprocess.Popen too

Use this if you already run your own subprocess.Popen(preexec_fn=...) supervision and want kernel rlimits and a UID drop. The UID drop is what makes RLIMIT_NPROC apply on Linux, where root is exempt from it. Adopting the rest of the library is optional.

confine_writes is irreversible and survives fork and execve. Call it after drop_root_privileges, which writes to RUNTIME_HOME. It raises where Landlock is missing. landlock_abi() returns the kernel's ABI version, or 0 where it cannot apply.

Compared to restricted-interpreter sandboxes (e.g. Monty)

Pydantic's Monty solves the same problem with a different strategy. It runs code in-process, in a restricted Python subset. Sandboxed code can call only what you inject as external functions. The interpreter supports no import os, no filesystem or network access, and no arbitrary pip packages. Its resource limits, meaning allocation count, duration and memory, are counters inside its own runtime. It runs no separate process.

Prefer pyplaypen-sandbox when code needs full CPython: numpy, pandas, any pip package, subprocesses of its own. Prefer it when you want the kernel to enforce the limits: a whole process tree killed on timeout, RLIMIT_NPROC and RLIMIT_AS, a UID drop. An interpreter's internal bookkeeping cannot see what a C extension or an injected function does with memory or subprocesses.

Prefer Monty when call volume or latency matters, since it starts no subprocess per call. Prefer it when the workload is a small fixed set of host capabilities. Prefer it when the threat model is untrusted input, and no per-tenant container isolation sits underneath. A restricted language holds on its own. Full CPython here holds because a container is around it.

Container reference

The repo's Dockerfile is the intended deployment. It is a Linux image that creates a dedicated non-root UID and installs the package. The parent stays root, and each call drops to that UID, which is what makes RLIMIT_NPROC apply. Base your own image on it, or copy the pattern.

You can instead run the container as a fixed non-root user, with no root parent and no per-call drop. Then add a container pids cap (docker run --pids-limit=N), so process_count is enforced at the container layer. Without one, Sandbox() refuses to construct (see "Enforcement checks"). That is the arrangement to pick for a mounted working tree, see "Running it".

CI builds the test stage and runs the suite as root, and again as the dedicated non-root UID with --pids-limit. A further step asserts that construction rejects an uncapped non-root container. Both deployments run on real Linux, where these checks do something.

Command line

Installing the package puts a pyplaypen executable on PATH. Prefix any command with it to run that command under enforced limits, with no code to write. Agent harnesses spawn Python as a subprocess. A harness uses the sandbox by putting pyplaypen run -- in front of the interpreter it already calls.

pyplaypen run [--limit name=value]... [--confine-writes] -- <argv>...
pyplaypen enforcement
pyplaypen run -- python script.py
pyplaypen run --limit wall_seconds=300 --limit memory_bytes=2_147_483_648 -- pytest -q
PYPLAYPEN_LIMITS=wall_seconds=60 pyplaypen run -- ./some-tool --flag

run starts your command in its own process group, in the current directory, with the current environment, capping CPU time, memory, open files and file size per process. It kills the whole group, including anything the command spawned, when wall_seconds runs out. Your command's stdout and stderr are printed when it exits, truncated at stdout_bytes/stderr_bytes, and you get its exit code back.

Any Limits field is a valid --limit name. Values are plain integers, and int() accepts _ separators. PYPLAYPEN_LIMITS=name=value,name=value sets defaults for every call, so a shim configures it once and a per-call --limit still wins. Negative and non-finite values are rejected. Each one disables its limit outright, which would let a --limit escape a shim's PYPLAYPEN_LIMITS policy in silence.

pyplaypen enforcement prints one line per limit saying what enforces it on this machine (hard, container, unsupported, ...). Run it at setup time to find out whether the guarantees you need are available here.

run refuses a limit it cannot keep. --limit process_count=4 on a non-root host with no pids cap exits 2 and tells you how to fix it. The same limit left at its default runs normally and prints one pyplaypen: line naming what goes unenforced here. PYPLAYPEN_QUIET=1 silences that line. The Python API refuses at construction time, see "Enforcement checks". PYPLAYPEN_AUDIT=1 prints the per-call audit record to stderr.

--confine-writes restricts the command to creating, modifying and deleting files under the current directory and a private TMPDIR that is removed when the run ends. Reads and program execution are unaffected. /dev/shm stays outside the write set, so multiprocessing.Pool raises PermissionError on its POSIX semaphore. It needs a kernel with Landlock. Where that is missing the run exits 2, and pyplaypen enforcement reports filesystem_writes: unsupported.

pyplaypen run --confine-writes -- python build.py   # writes land in . and TMPDIR

Exit codes. Your command's own, or 128+signum where a signal killed it. 124 on timeout. 125 where the sandbox could not run it at all. 2 for a usage error or a refused limit.

Every line this command writes to stderr starts with pyplaypen:, which separates it from your command's own output. It also names the limit behind a signal death that would otherwise arrive as a bare -24.

What it does not do. No stdin: your command reads EOF immediately, so a bare interpreter or a - argument is refused. Running an empty program in silence would be worse. python -c, python script.py and pytest are unaffected. No streaming: output arrives when the command exits. Writes are unconfined until you pass --confine-writes: cwd is your own project directory. Metadata changes (chmod, chown, utime) stay unrestricted with the flag on. Text output only: decoded with errors="replace", so binary stdout is mangled. A file_bytes breach is silent: the write is truncated at the cap, and your command is not told. Under root your command runs as a different user: it can write only what that UID may write, and this command changes nothing to help it. See "Running it".

Running it

Run the container as the owner of the directory you mount. Nothing to prepare, and files come back owned by you:

docker run --user $(id -u):$(id -g) --pids-limit 64 \
    -v "$PWD":/work -w /work IMAGE pyplaypen run --confine-writes -- pytest

process_count then reads "container", enforced by the cgroup at --pids-limit. Limits.process_count is ignored. Every other limit is unchanged.

Run as root where you want process_count bound to your own Limits value. Each call drops to a dedicated UID, which is what makes RLIMIT_NPROC apply, and that UID has to be allowed into the tree first:

setfacl -R    -m u:$(id -u):rwX,u:65534:rwX .
setfacl -R -d -m u:$(id -u):rwX,u:65534:rwX .
docker run --pids-limit 512 -v "$PWD":/work -w /work IMAGE \
    pyplaypen run --confine-writes -- pytest

Both entries are needed. The second lets the sandbox write. The first keeps what it wrote writable by you. -d sets the default ACL that new files inherit, which also makes the target's umask irrelevant. Where the filesystem carries no ACLs, use the first arrangement.

-R covers the entries that exist when you run it. A directory mounted or created afterwards keeps its own permissions.

Enforcement checks

On Linux, Sandbox() fails at construction where a limit it accepts cannot be enforced on this deployment. A limit that is set and does nothing gives no protection while looking like it does. self_check=True refuses a broken runner for the same reason.

Every limit is checked. On Linux, the enforceability of process_count depends on the deployment. Every other limit applied to every call is enforced either by the supervisor itself or by a per-process rlimit that always applies.

A non-root container with no process cap cannot enforce process_count, so that deployment is rejected until you fix it. Run the container with the parent as root, so each call drops to a dedicated UID and RLIMIT_NPROC binds to your value. Or set a container-level pids cap (docker run --pids-limit=N). Or pass warn_only=True to acknowledge the gap and proceed. The rlimit checks run on Linux alone. Other platforms are dev hosts, and the rlimits checked here do not apply there.

filesystem_writes is the one opt-in entry. It reads "available" where the host has Landlock and this Sandbox did not ask for it. It reads "hard_content" once Sandbox(confine_writes=True) has applied it on Landlock ABI 3 or later. It reads "hard_content_except_truncate" on ABI 1 and ABI 2. It reads "unsupported" where the kernel has no Landlock. Construction refuses where a caller asked for a confinement the kernel cannot deliver. That refusal runs on every platform, including macOS, where the kernel has no Landlock at all.

"hard_content" claims file contents and directory entries. Landlock leaves chmod, chown, setxattr, utime, flock, stat and fcntl unrestricted, so a confined call can still change the mode, owner or timestamps of any file it can reach.

"hard_content_except_truncate" claims file contents and directory entries as well. A confined call cannot create, delete, rename or write bytes into a file outside the write set. LANDLOCK_ACCESS_FS_TRUNCATE arrived in ABI 3, kernel 6.2. Below that a confined call truncates any file its uid can reach to zero bytes, anywhere on the filesystem. Kernels 5.13 through 6.1 land here once landlock is in the active LSM list, which "Platform notes" covers. Run pyplaypen enforcement for the value your own host reports.

Sandbox.enforcement exposes the same values as a dict, one per limit ("hard", "hard_per_user", "container", "hard_content", "hard_content_except_truncate", "available", "unsupported", ...). Check it in your own deploy where you need a specific guarantee:

sandbox = Sandbox()
assert sandbox.enforcement["process_count"] != "unsupported"

Platform notes

On macOS, cpu_seconds, file_bytes and open_files are enforced natively. The wall-clock timeout and process-group teardown apply everywhere. memory_bytes and process_count are reported "unsupported". Darwin can enforce neither: RLIMIT_AS aliases the unenforced RLIMIT_RSS, and per-UID RLIMIT_NPROC needs a root drop that this deployment does not support. For full enforcement on a Mac, run the Linux image in a Linux VM (Apple container, Colima, or Docker). Sandbox.enforcement reports which limits apply on the current host. The root-UID drop is a no-op where the parent does not run as root.

process_count binds to your exact Limits value only where the parent is root and drops to a dedicated UID. RLIMIT_NPROC counts per real UID, so it is safe to set once this process owns its UID.

"Dedicated" is the deployment's job. The kernel counts every process with that UID in one number, across containers and the host alike, so two containers both using the default 65534 share one budget. Concurrent calls in a single Sandbox share it too: at max_concurrency=4 the value bounds the four together. Give each container its own child_uid, keep max_concurrency=1, or use a pids cap, which bounds the container whatever UID the calls run as.

A non-root container relies on a container-level pids cap instead (docker run --pids-limit, a Kubernetes pod pids cgroup, systemd TasksMax). The kernel enforces that cap at the operator's number. Limits.process_count is ignored.

Sandbox.enforcement["process_count"] reports which you have. "hard_per_user" means root, a non-zero child_uid, and your value. "container" means a cgroup cap at the operator's value. "unsupported" means neither, and construction rejects it. child_uid=0 cancels the drop, so it reports the cgroup cap or "unsupported".

confine_writes needs Landlock. That means CONFIG_SECURITY_LANDLOCK=y and landlock in the kernel's active LSM list, set through CONFIG_LSM or the lsm= boot parameter. Arch and Fedora ship it enabled. Debian ships it disabled (#999551). macOS reports "unsupported".

It needs no root and no added capability. It passes Docker's default seccomp profile as an unprivileged uid, measured at ABI 8 in the reference image. A container has no /sys/kernel/security/lsm to read, so Sandbox.enforcement["filesystem_writes"] is the check to run.

Confinement applies to root as well. With it on, NO_NEW_PRIVS stops a run_process exec of a setuid binary from escalating, a globals_provider runs confined, and .pyc writes into site-packages fail in silence.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pyplaypen_sandbox-0.5.0.tar.gz (72.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pyplaypen_sandbox-0.5.0-py3-none-any.whl (40.4 kB view details)

Uploaded Python 3

File details

Details for the file pyplaypen_sandbox-0.5.0.tar.gz.

File metadata

  • Download URL: pyplaypen_sandbox-0.5.0.tar.gz
  • Upload date:
  • Size: 72.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pyplaypen_sandbox-0.5.0.tar.gz
Algorithm Hash digest
SHA256 cf09a5f81d012e92a66b3a5ae7bf6a8d202d6a30777036b7a501d11957123daa
MD5 019e5743670b54928efc1158bfc3d804
BLAKE2b-256 c680e62b18a8a573fcc033756ad3bc0fe834eaade6334e4bee090701ec4d3219

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyplaypen_sandbox-0.5.0.tar.gz:

Publisher: publish.yml on lkraider/pyplaypen-sandbox

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pyplaypen_sandbox-0.5.0-py3-none-any.whl.

File metadata

File hashes

Hashes for pyplaypen_sandbox-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 13fd13702dc62297895b4b339792ce75f4426eb8c83383eb14922114ac928f83
MD5 63ef9e247112f123584018fed99db11e
BLAKE2b-256 d0a342cf1373d043d2e8528b902b6d5af933f24a1cf6f826c1917da2ada1b383

See more details on using hashes here.

Provenance

The following attestation bundles were made for pyplaypen_sandbox-0.5.0-py3-none-any.whl:

Publisher: publish.yml on lkraider/pyplaypen-sandbox

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 files

0.4.0

2 files

0.3.0

2 files

0.2.1

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page