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_SUBREAPERon 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_mapinsupervisor.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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cf09a5f81d012e92a66b3a5ae7bf6a8d202d6a30777036b7a501d11957123daa
|
|
| MD5 |
019e5743670b54928efc1158bfc3d804
|
|
| BLAKE2b-256 |
c680e62b18a8a573fcc033756ad3bc0fe834eaade6334e4bee090701ec4d3219
|
Provenance
The following attestation bundles were made for pyplaypen_sandbox-0.5.0.tar.gz:
Publisher:
publish.yml on lkraider/pyplaypen-sandbox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyplaypen_sandbox-0.5.0.tar.gz -
Subject digest:
cf09a5f81d012e92a66b3a5ae7bf6a8d202d6a30777036b7a501d11957123daa - Sigstore transparency entry: 2415067528
- Sigstore integration time:
-
Permalink:
lkraider/pyplaypen-sandbox@fecfdd1277437ee63c50c1d296c7fd1499032e93 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/lkraider
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@fecfdd1277437ee63c50c1d296c7fd1499032e93 -
Trigger Event:
release
-
Statement type:
File details
Details for the file pyplaypen_sandbox-0.5.0-py3-none-any.whl.
File metadata
- Download URL: pyplaypen_sandbox-0.5.0-py3-none-any.whl
- Upload date:
- Size: 40.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
13fd13702dc62297895b4b339792ce75f4426eb8c83383eb14922114ac928f83
|
|
| MD5 |
63ef9e247112f123584018fed99db11e
|
|
| BLAKE2b-256 |
d0a342cf1373d043d2e8528b902b6d5af933f24a1cf6f826c1917da2ada1b383
|
Provenance
The following attestation bundles were made for pyplaypen_sandbox-0.5.0-py3-none-any.whl:
Publisher:
publish.yml on lkraider/pyplaypen-sandbox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyplaypen_sandbox-0.5.0-py3-none-any.whl -
Subject digest:
13fd13702dc62297895b4b339792ce75f4426eb8c83383eb14922114ac928f83 - Sigstore transparency entry: 2415067580
- Sigstore integration time:
-
Permalink:
lkraider/pyplaypen-sandbox@fecfdd1277437ee63c50c1d296c7fd1499032e93 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/lkraider
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@fecfdd1277437ee63c50c1d296c7fd1499032e93 -
Trigger Event:
release
-
Statement type: