senv manual
senv runs uv and your code inside an OS-level sandbox. Installs reach package registries and nothing else, and cannot write to your source tree. Your code runs with no network by default, an environment it cannot modify, and no access to your credentials.
A senv project stays a valid uv project. senv adds no files to your tree beyond an optional
senv.toml, and every command below has the uv command you already know underneath it.
What senv is
A virtual environment is a PATH convention, not a security boundary. Packages inside one
can read ~/.ssh, take your environment variables, open sockets to any destination, and
execute build code during installation with your full user permissions. senv keeps the workflow and puts
a boundary underneath it, with a separate policy for each moment that carries risk.
| Install · sync / add / lock | Run · run / shell | |
|---|---|---|
| Network | PyPI and configured indexes only | denied by default |
| Project source | read-only | read-write |
| Python environment | writable for installation | read-only |
| Credentials | unavailable | only explicitly declared secrets |
| Resource limits | CPU, file size, wall clock; memory and processes on Linux | CPU and file size; memory and processes on Linux |
senv is not a package scanner. It does not decide whether a dependency is malicious; it decides what a dependency is able to do. Lockfile hashes and dependency review remain as important as they were.
Install
curl -fsSL https://senv.h5i.dev/install.sh | sh # prebuilt binary, checksum-verified
uv tool install h5i-senv # also: pipx install h5i-senv, pip install h5i-senv
cargo install --git https://github.com/h5i-dev/senv
The script picks the build for your platform, verifies it against the SHA-256 published beside it, and
installs to /usr/local/bin. SENV_INSTALL_DIR changes the destination and
SENV_VERSION=vX.Y.Z pins a version.
senv requires uv on PATH.
- Linux: restricting installs to package registries also requires
slirp4netnsandnftables(sudo apt install slirp4netns nftableson Debian and Ubuntu). - macOS: no additional sandbox runtime. senv uses the built-in Seatbelt sandbox.
- Windows: use WSL2.
Run senv doctor before relying on any of it: it reports what this host can actually
enforce, so a gap surfaces before it fails mid-workflow.
The two boundaries
senv has three phases. Two of them are boundaries you interact with; the third exists so the first two have something trustworthy to stand on.
Provisioning
Installing an interpreter (uv python install, triggered by senv init --python)
runs in its own phase with no third-party code in it. Interpreters are therefore shared across projects
and read-only everywhere else, which is why a shared interpreter cache is safe when a shared
wheel cache is not.
The install boundary
Supply-chain attacks execute before a package is ever imported: an sdist build backend runs during installation, with whatever permissions the installing user has.
senv resolves and installs in a staging area containing the project manifests and none of the project source, then verifies and copies the result back. Network access is restricted to package registries and pinned at the network layer, and no secret can name the install phase at all.
Two escape hatches exist, both explicit and both recorded:
--in-placeonadd,remove,lock, anduvresolves in the project directory instead of a staging copy, giving the install phase write access to your source. It is announced and stamped into the receipt.project-writable = truein[install]is the persistent form, for build backends (older setuptools layouts in particular) that insist on writing metadata into the source tree. senv detects that specific failure and names the key rather than guessing.
The run boundary
While your code runs, the project is read-write and the environment is not. The managed environment
lives outside the project tree, and .venv points at it without exposing a writable parent
directory. Landlock evaluates the resolved target of that symlink, so a sandboxed process that replaces
the link gains nothing.
senv compiles bytecode during installation and disables writable bytecode caches at run time. Without
that, a package could leave behind a modified .pyc that Python would prefer over the
protected source — a persistence path around a read-only environment.
Environment variables are not inherited. The child gets senv's baseline — PATH,
HOME, LANG, TERM, COLORTERM — plus whatever
you list in [run.env] pass, plus any declared secrets.
Commands
senv init [--python V] [--replace-venv] [--no-sync]
senv add <pkg>... [--group G] [--dev] [--in-place]
senv remove <pkg>... [--group G] [--dev] [--in-place]
senv sync [--frozen] [-- <uv args>...]
senv lock [--check] [--in-place]
senv run [--allow-net HOST]... <cmd> [args...]
senv shell [--allow-net HOST]...
senv status
senv report [--limit N] [--suggest]
senv allow <host>...
senv trust
senv doctor
senv gc [--prune] [--cache]
senv uv [--in-place] -- <args>...
Global options
| Flag | Effect |
|---|---|
--json | Emit machine-readable JSON instead of prose. Available on every subcommand, including error output. |
--project DIR | Operate on this project instead of searching upward from the working directory. |
Exit codes. A confined command's own exit code passes through untouched, so
senv run pytest is a drop-in for uv run pytest in a CI script. senv's own
failures use exit code 2. A child killed by a signal still produces a non-zero code rather
than wrapping to zero.
senv init
Create or adopt a project, then build its environment. In an existing project this adopts
rather than scaffolds: pyproject.toml, uv.lock, and
.python-version are taken as they are.
| Flag | Effect |
|---|---|
--python VERSION | Python version for uv to use, e.g. 3.13. Written to .python-version, which is uv's own mechanism. |
--replace-venv | Replace an existing .venv directory with a link to senv's environment. |
--no-sync | Set up the project without building the environment. |
An existing .venv is left alone by default, because those packages were installed outside
the boundary and senv does not claim a boundary over bytes that never passed through one. senv creates
a separate managed environment and explains how to switch; until the first sandboxed sync,
senv status reports the environment as host-installed rather than sandboxed.
senv add / senv remove
Add or remove dependencies. Resolution happens in a staging copy of your manifests, the result is written back, and the environment is synced.
| Flag | Effect |
|---|---|
--group GROUP | Add to a dependency group (uv's --group). |
--dev | Add as a development dependency. |
--in-place | Resolve in the project directory instead of a staging copy, giving the install phase write access to your source. Announced and recorded. |
senv sync
Install the locked dependencies into the environment.
| Flag | Effect |
|---|---|
--frozen | Fail instead of updating a stale lockfile. The right default for CI. |
<uv args> | Trailing arguments are passed to uv sync unchanged. |
senv lock
Update the lockfile without touching the environment. --check verifies the lockfile is
current and writes nothing; --in-place behaves as it does for add.
senv run
Run a command inside the run boundary. Flags after the command name belong to the command, not to senv.
senv run pytest
senv run python -m myapp --port 8000
senv run --allow-net api.example.com python fetch.py
| Flag | Effect |
|---|---|
--allow-net HOST | Let this command reach a host, for this invocation only. Repeatable. Widening beyond senv.toml is announced and recorded in the receipt. |
senv shell
Open an interactive shell inside the run boundary — the equivalent of
source .venv/bin/activate, except that the boundary comes with it. Takes the same
--allow-net flag as run.
senv status
Show the environment and the policy that is enforced: the resolved policy per phase, the isolation tier actually selected on this host, current grants, resource limits, whether the environment was built inside the boundary or adopted, and the digest of each phase's policy.
Limits your platform cannot apply are marked as unenforced rather than printed as ceilings. A
configured downgrade such as install.net = "host" is reported prominently here.
senv report
Show what ran, what was denied, and what was redacted, from the receipts.
| Flag | Effect |
|---|---|
--limit N | How many recent commands to show. Default 20. |
--suggest | Print a senv.toml stanza that would allow everything recorded as blocked. Nothing is written; applying it stays an explicit, diffable edit. |
There is deliberately no observe mode that runs your code unconfined to learn a policy. Denial-driven suggestion gets the same information out of confined runs.
senv allow
Allow the run phase to reach a host, writing the grant into senv.toml and accepting it as
the new baseline in the same step.
senv allow api.example.com
senv allow '*.s3.amazonaws.com' api.example.com:8443
Patterns may carry a port. A single-label wildcard such as *.com is refused outright: it is
the one typo that turns an allowlist into an open door.
Because this command widens the policy and then re-baselines it, it starts from a policy you have
already accepted — if senv.toml has drifted from the trusted snapshot,
allow refuses rather than launder someone else's edit through your legitimate one. And if
[run] net is already "host", nothing is added and senv says so: turning
unrestricted egress into an allowlist here would silently narrow a policy you did not ask it to
narrow.
senv trust
Accept the current senv.toml as the trusted baseline. Required after any widening edit
— see Trust.
senv doctor
Report what this host can enforce: which isolation tiers are available, whether a registry allowlist can be enforced during installs, and which resource limits apply on this platform. Run it before trusting the boundary on a new machine or a CI image.
senv gc
Find state belonging to projects that no longer exist. Without --prune it only reports
what it would remove; --cache includes this project's wheel cache.
senv uv
Run an arbitrary uv command inside the install boundary, for anything senv does not wrap.
senv uv -- tree
senv uv -- pip list
Configuration
senv.toml sits in the project root, is checked in, and is entirely optional. Without it,
the fail-closed defaults apply. The file first appears when you widen something —
senv allow creates it.
Layering is: built-in defaults ← senv.toml ← per-invocation flags. Widening
beyond the file requires a flag, and the flag is announced and recorded.
Unknown keys are an error. The schema rejects them everywhere. A misspelled key in a security policy must fail loudly rather than sit in the file reading as though it were enforced.
[env]
[env]
python = "3.13"
isolation = "auto" # auto | process | supervised | container | microvm
image = "..." # required by the container and microvm tiers
uv = "/opt/bin/uv" # absolute path, when uv is not on PATH
allow-command-secrets = false
| Key | Meaning |
|---|---|
python | Python version for uv to use. |
isolation | Tier to enforce. auto picks the strongest tier the host supports for each phase. workspace is rejected: that h5i tier applies no confinement, and senv has no unconfined execution path. |
image | Base OCI image. Required by the container and microvm tiers. |
uv | Absolute path to the uv binary. Rejected if it lives inside the project or inside senv's state, where sandboxed code could rewrite the binary the install phase is about to run. |
allow-command-secrets | Permit source = "command:…" secrets, which execute on the host, outside the sandbox, with your full environment. Off by default, and separate from the secret declaration itself so a package that edits this file cannot enable it quietly. |
[install]
[install]
extra-indexes = ["download.pytorch.org"]
net = "registries" # "registries" (default) | "host" (warned downgrade)
cache = "project" # "project" (default) | "shared"
read = ["~/wheels"] # extra read-only grants during installs
project-writable = false
[install.resources]
mem = "8G"
wall = "60m"
| Key | Meaning |
|---|---|
extra-indexes | Package registries beyond PyPI, added to the install-phase allowlist. |
net | registries restricts egress to the package indexes. host is the explicit, warned downgrade for hosts that cannot enforce a domain allowlist; it is never selected automatically and is stamped into every receipt. |
cache | project keeps the wheel cache per project. A wheel cache is written during the install phase, which is when third-party build backends run, so shared means a compromised install in one project can reach another. It saves disk and trades away isolation. |
read | Extra read-only paths the install phase may see — for a local path dependency outside the project tree. |
project-writable | Let the install phase write to the project directory. Off by default. Needed only by build backends that insist on writing metadata into the source tree. |
[run]
[run]
net = "deny" # "deny" | "host" | ["api.example.com", "*.s3.amazonaws.com"]
[run.fs]
read = ["~/datasets"] # extra read-only grants
write = [] # the project and a scratch dir are already writable
[run.env]
pass = ["MY_APP_MODE"] # added to senv's baseline, never replacing it
[run.resources]
mem = "4G"
wall = "30m" # "none" for a dev server
procs = 256
fsize = "1G"
cpu = "10m"
| Key | Meaning |
|---|---|
net | deny is a real deny on every backend. A list is an allowlist. host is unrestricted egress and has to be written deliberately. |
fs.read / fs.write | Grants beyond the project, the environment, and senv's scratch directory. |
env.pass | Host environment variables forwarded to the command, added to senv's baseline of PATH, HOME, LANG, TERM, and COLORTERM. |
resources.mem | Memory ceiling. Linux cgroups; not enforceable on macOS. |
resources.wall | Wall-clock ceiling. "none" is the escape hatch for dev servers and watchers: it resolves to a very long but finite wall, so the kill switch still exists and still appears in the digest. |
resources.procs | Process ceiling. Linux cgroups; not enforceable on macOS. |
resources.fsize | Maximum size of a single file. An rlimit; applies everywhere. |
resources.cpu | CPU-time backstop. An rlimit; applies everywhere. |
[secrets]
[secrets.OPENAI_API_KEY]
source = "env:OPENAI_API_KEY" # env:VAR | file:/abs/path | command:<shell>
inject = "env" # "env" (default) | "file"
phases = ["run"] # "run" and/or "shell" — never "install"
The table key is the environment-variable name the child receives. A secret can never name the install phase: a credential visible to a dependency's build backend is a credential handed to whoever wrote that backend.
A command: source runs on the host, outside the sandbox, so it additionally requires
allow-command-secrets = true in [env]. Declared secret values are stripped
verbatim from receipts before writing, and h5i's credential scanner checks what remains for anything
else that looks like a key.
Policy
Isolation tiers
| Tier | Mechanism | Where senv uses it |
|---|---|---|
process | Landlock, seccomp-bpf, namespaces, and rlimits on Linux; Seatbelt on macOS | Default run tier. |
supervised | Adds a private network namespace with nftables rules pinned to resolved addresses, and syscall-gated sockets | Default install tier on Linux, because a registry allowlist needs it. |
container | Rootless Podman, read-only rootfs, DNS-pinned CONNECT proxy | Opt-in, and the fallback when supervised is unavailable but Podman is. The one tier with a direct per-request egress tally. |
microvm | A separate kernel, through microsandbox | Opt-in, for when a shared kernel is not an acceptable assumption. |
The Linux process tier cannot enforce a domain allowlist — its network modes are
all-or-nothing. So senv run with the default net = "deny" works everywhere at
the lightest tier, while senv sync needs supervised (which needs
slirp4netns, nft, and cgroup-v2 delegation) or container. On
macOS, Seatbelt covers installs at the base tier.
senv never silently downgrades. On a host with none of the above, senv sync
is refused with the senv doctor explanation rather than run with an unenforced allowlist.
Platform matrix
| Platform | Enforcement | Gaps |
|---|---|---|
| Linux | Landlock filesystem allowlists, seccomp-bpf syscall filtering, namespaces, rlimits and cgroups. Network allowlists use a private namespace and nftables rules pinned to resolved addresses. | Registry allowlisting needs slirp4netns and nftables. |
| macOS | Seatbelt filesystem and network confinement. Allowed egress passes through a DNS-pinned loopback proxy, and other name resolution is denied outright — so the allowlist holds against any client, including one that ignores proxy settings. | No seccomp. No cgroups, so memory and process ceilings are marked unenforced rather than applied. Apple's system Python caches bytecode outside the environment. |
| Windows | Via WSL2, as Linux. | Some WSL2 setups lack the pieces supervised needs; senv doctor says so. |
Host patterns
A host entry is a domain, optionally with a leading *. wildcard and optionally with a
port: api.example.com, *.s3.amazonaws.com,
registry.internal:8443. Patterns are validated where you can see them, and a single-label
wildcard such as *.com is refused.
An allowlist is expressed as deny plus a host list, which means the same thing on every
backend. This matters: pairing a host list with net = "host" reads as an allowlist and
compiles to unrestricted egress on Seatbelt.
Trust
senv.toml lives inside the project, and the run phase grants the project read-write. A
compromised dependency can therefore edit the policy. senv keeps the last accepted policy snapshot
outside the sandbox so that edit cannot take effect silently:
- an unchanged or narrower policy runs normally;
- a wider policy is refused, with a diff, until you inspect and accept it with
senv trust.
senv: senv.toml grants more than senv recorded, so nothing was run
the policy on disk is wider than the one you last accepted:
+ [run] net: deny → unrestricted
+ [run.env] pass: added AWS_SECRET_ACCESS_KEY
→ if you made this change, run `senv trust`; if you did not,
inspect senv.toml and your recent dependencies first
The same check applies the first time senv sees a project whose policy is wider than the defaults, and
to the pyproject.toml sections that control install behavior: [build-system]
and [tool.uv].
This is tamper-evidence, not tamper-prevention. A dependency can still write to a writable project. What it cannot do is have the write take effect without a human step.
Receipts
Every command appends a receipt outside the sandbox: append-only JSONL, one line per command, holding what ran, what was denied, what was redacted, and a sha256 digest of the phase policy that was actually enforced. Command output is kept only when the command failed — a successful run's output is your business, not the log's.
Receipts live outside every grant senv issues, and an integration test asserts that a process under a run policy cannot write the receipt log. This is why senv's state cannot live in the project: receipts a compromised package can rewrite are not evidence.
Two honest limits. Kernel-tier denial reports infer some denials from program output, which untrusted code can influence, so every suggested grant deserves review before you accept it; the container tier is the one with a direct per-request tally. And a receipt is protected from the sandboxed process, not notarized against the person who owns the machine.
Files
project/ # senv adds nothing here that was not already yours
senv.toml # optional policy source (checked in)
pyproject.toml, uv.lock # uv's domain (checked in)
.venv -> …/state/…/venv # a symlink, so editors and language servers work
~/.local/state/senv/projects/<name>-<hash>/
venv/ # the environment itself
cache/ # this project's wheel cache (default)
scratch/ # run-phase writable scratch, incl. the pycache prefix
tmp/{install,run,provision} # per-phase TMPDIR
stage/ # manifest staging for lock/add/remove
receipt.jsonl # append-only: what ran, denials, digests
policy.{install,run}.toml # the resolved policy, for inspection
state.json # provenance, lock hash, last digests
~/.cache/senv/
uv/ # shared wheel cache (opt-in)
python/ # uv-managed interpreters, shared, provisioned alone
The policy.*.toml files are written for inspection and never read back as input: senv
recompiles the policy on every invocation.
Environment variables
| Variable | Effect |
|---|---|
SENV_STATE_DIR | Where senv keeps per-project state. Defaults to $XDG_STATE_HOME/senv, then ~/.local/state/senv. |
SENV_CACHE_DIR | Where senv keeps shared caches. Defaults to $XDG_CACHE_HOME/senv, then ~/.cache/senv. |
SENV_UV | Path to the uv binary, taking precedence over PATH. Subject to the same location check as [env] uv. |
SENV_INSTALL_DIR | Installer only. Install destination. Defaults to /usr/local/bin. |
SENV_VERSION | Installer only. Pin a release, e.g. v0.1.0. |
senv refuses to start if SENV_STATE_DIR or SENV_CACHE_DIR points
inside the project. This is not a hypothetical: keeping senv's state in the workspace so CI
can cache it puts the environment, the receipts, and the trusted baseline inside the run phase's write
grant, and every guarantee on this page collapses at once. A state root that merely contains
the project is fine.
Using senv in CI
senv doctor # fail early if this runner cannot enforce the policy
senv sync --frozen # install exactly what the lockfile says
senv run pytest # exit code passes through
senv report --json # what ran and what was denied, for the job log
Two things to plan for. A runner without slirp4netns and nftables, and
without Podman, cannot enforce a registry allowlist, so senv sync is refused rather than
quietly downgraded — install those packages in the image, or set
install.net = "host" deliberately and accept that it appears in senv status
and in every receipt. And do not point SENV_STATE_DIR at the workspace to cache it between
jobs; senv will refuse.
Limits
- The default tiers share the host kernel. Landlock, seccomp, and Seatbelt are
OS-level isolation, not a hypervisor boundary. Use
isolation = "microvm"when a separate kernel is required. - senv does not identify malicious packages. Lockfile hashes and dependency review remain important.
- Run the environment through senv.
source .venv/bin/activate, an editor invoking.venv/bin/python, or any other host-side execution bypasses the run boundary. - Install-time code can modify the environment. Installation must write packages and scripts; the install sandbox protects your source, credentials, and non-registry network, but cannot make the environment itself read-only.
- Denial suggestions are hints, not proof. Kernel-tier reports infer some denials from program output, which untrusted code can influence.
- State must remain outside the project. See environment variables.
- Runtime wall-clock limits are not currently enforced. CPU and file-size rlimits apply everywhere; memory and process limits use Linux cgroups and do not apply on macOS.
- macOS Python startup can vary by interpreter. Apple's system Python may recompile
imports on each run, because its bytecode cache is outside the managed environment.
senv init --python 3.13selects a managed interpreter without that behavior. - Policy tampering is detected, not prevented.
See also
- senv overview — what the boundary is and why the two phases differ.
- DESIGN.md — the full threat model, the design rationale, and the decisions that changed during implementation.
- h5i — the sandbox engine senv links, and the tool that confines a whole coding-agent session rather than one Python project.
- uv documentation — the workflow underneath.