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 / lockRun · run / shell
NetworkPyPI and configured indexes onlydenied by default
Project sourceread-onlyread-write
Python environmentwritable for installationread-only
Credentialsunavailableonly explicitly declared secrets
Resource limitsCPU, file size, wall clock; memory and processes on LinuxCPU 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.

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:

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

FlagEffect
--jsonEmit machine-readable JSON instead of prose. Available on every subcommand, including error output.
--project DIROperate 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.

FlagEffect
--python VERSIONPython version for uv to use, e.g. 3.13. Written to .python-version, which is uv's own mechanism.
--replace-venvReplace an existing .venv directory with a link to senv's environment.
--no-syncSet 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.

FlagEffect
--group GROUPAdd to a dependency group (uv's --group).
--devAdd as a development dependency.
--in-placeResolve 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.

FlagEffect
--frozenFail 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
FlagEffect
--allow-net HOSTLet 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.

FlagEffect
--limit NHow many recent commands to show. Default 20.
--suggestPrint 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
KeyMeaning
pythonPython version for uv to use.
isolationTier 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.
imageBase OCI image. Required by the container and microvm tiers.
uvAbsolute 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-secretsPermit 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"
KeyMeaning
extra-indexesPackage registries beyond PyPI, added to the install-phase allowlist.
netregistries 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.
cacheproject 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.
readExtra read-only paths the install phase may see — for a local path dependency outside the project tree.
project-writableLet 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"
KeyMeaning
netdeny 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.writeGrants beyond the project, the environment, and senv's scratch directory.
env.passHost environment variables forwarded to the command, added to senv's baseline of PATH, HOME, LANG, TERM, and COLORTERM.
resources.memMemory ceiling. Linux cgroups; not enforceable on macOS.
resources.wallWall-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.procsProcess ceiling. Linux cgroups; not enforceable on macOS.
resources.fsizeMaximum size of a single file. An rlimit; applies everywhere.
resources.cpuCPU-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

TierMechanismWhere senv uses it
processLandlock, seccomp-bpf, namespaces, and rlimits on Linux; Seatbelt on macOSDefault run tier.
supervisedAdds a private network namespace with nftables rules pinned to resolved addresses, and syscall-gated socketsDefault install tier on Linux, because a registry allowlist needs it.
containerRootless Podman, read-only rootfs, DNS-pinned CONNECT proxyOpt-in, and the fallback when supervised is unavailable but Podman is. The one tier with a direct per-request egress tally.
microvmA separate kernel, through microsandboxOpt-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

PlatformEnforcementGaps
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:

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

VariableEffect
SENV_STATE_DIRWhere senv keeps per-project state. Defaults to $XDG_STATE_HOME/senv, then ~/.local/state/senv.
SENV_CACHE_DIRWhere senv keeps shared caches. Defaults to $XDG_CACHE_HOME/senv, then ~/.cache/senv.
SENV_UVPath to the uv binary, taking precedence over PATH. Subject to the same location check as [env] uv.
SENV_INSTALL_DIRInstaller only. Install destination. Defaults to /usr/local/bin.
SENV_VERSIONInstaller 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

See also