Metadata-Version: 2.4
Name: pyplaypen-sandbox
Version: 0.5.0
Summary: One-shot subprocess sandbox for running Python inside a container: process-group isolation, POSIX rlimits, UID drop, bounded I/O, artifact capture.
Author-email: Paul Eipper <paul@redevistorias.com.br>
License: MIT
Project-URL: Homepage, https://lkraider.github.io/pyplaypen-sandbox/
Project-URL: Repository, https://github.com/lkraider/pyplaypen-sandbox
Project-URL: Issues, https://github.com/lkraider/pyplaypen-sandbox/issues
Keywords: sandbox,subprocess,rlimit,isolation,security
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Framework :: AsyncIO
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
Requires-Dist: numpy; extra == "dev"
Requires-Dist: pandas; extra == "dev"
Dynamic: license-file

# 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

```python
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:

```python
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

```python
{
    "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.

```python
# 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}
```

```python
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.

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

```python
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:

```python
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:

```python
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:

```python
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](https://github.com/pydantic/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
```

```bash
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`.

```bash
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:

```bash
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:

```bash
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:

```python
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](https://bugs.debian.org/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.

