- Python 89%
- Shell 9.2%
- TypeScript 1%
- Dockerfile 0.6%
- JavaScript 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Follow-up to #13 (merged as `
|
||
| .forgejo/workflows | ||
| docs | ||
| umwelt | ||
| .gitignore | ||
| AGENTS.md | ||
| CONFIGURATION.md | ||
| icon.png | ||
| LICENSE | ||
| pyproject.toml | ||
| pytest.ini | ||
| README.md | ||
umwelt
Letting an LLM run shell commands on your machine is a bold architectural choice.
umwelt is the safety net for that choice: one podman container per
project, closed by default. Inside, the agent sees its workspace and a
handful of read-only mounts - nothing else, and no network. When it needs
more, it asks and you decide; the wall moves one grant at a time - a key
file, a second directory, one git host, one loopback port - and every grant
is logged. Model- and LLM-agnostic.
umwelt is a hardened podman sandbox for LLM agents: the agent's shell
commands and file tools run inside the container, and the container is the
boundary. No model key lives in this repo, and the LLM server (e.g. an
OpenAI-compatible endpoint) is run and configured separately.
How it works
opencode agent
| (MCP over stdio, JSON-RPC 2.0)
umwelt/mcp_server.py attaches to the workspace's control plane,
| (Unix socket) or launches one and kills it on exit
umwelt/run.py control plane, one per workspace:
| (podman) owns the container, serves the socket API,
umwelt-<sid> runs a watchdog that recreates the container
if it dies
- Session identity. Each workspace maps to a stable 8-hex session id
(SHA-256 of the workspace's realpath), so the same project always gets
the same sandbox, from any agent or session (container
umwelt-<sid>, socket$XDG_RUNTIME_DIR/umwelt-<sid>.sock, mode 0600). - Attach vs launch. The MCP server attaches when the socket answers,
otherwise it launches
run.py(waits up to 60 s for the socket) and SIGTERMs that process when the agent exits; a second process for the same workspace is refused at serve time and never touches the winner's socket. - Self-heal.
initializesucceeds even when the control plane is down; the first tool call lazily (re)attaches or relaunchesrun.py, and a tool call that hits a connection error re-attaches and retries once (5 s backoff between failed attempts). - Lifecycle. If the container dies, the watchdog (5 s poll) recreates
it under the same name; grant/revoke changes recompute the effective
mount set and recreate the container in place.
run.pyexits 0 on clean shutdown, 1 on start failure, 2 on bad config/workspace, 3 when cgroup delegation is missing (the installer's cgroup step).
Full lifecycle details: Control plane.
Security floor
Five flags are hardcoded in run.py and never configurable through any
config file (the one opt-in exception is egress, which replaces
--network=none only for a workspace whose effective config enables egress
and whose preconditions all hold):
| Flag | Effect |
|---|---|
--network=none |
No network interfaces at all - no egress, no exfil over the wire, nothing inside can reach the host's services. The default for every sandbox. |
--read-only |
Read-only root filesystem - no planting files in the image, no tampering with system binaries, no persistence outside the workspace. |
--userns=keep-id |
Maps the container user to the host user's own uid/gid, so host-file grants, read ACLs, and the workspace mount all resolve to the same identity. |
--security-opt=no-new-privileges |
Processes can never gain new privileges (setuid/setgid transitions are dead on arrival). |
--security-opt=label=disable + --tmpfs /dev/shm |
SELinux-enforcing hosts only - keeps the bind grants usable while the floor above still holds. |
On top of the floor, the resource caps from the committed [resources]
table (memory, cpus, pids_limit) are always applied to every
container (a project can raise them for itself with a [resources] table in
its own .umwelt.toml) - applying them requires the one-time cgroup
delegation the
installer sets up, and run.py refuses to start without it (there is no
silent weakening path). The host sees the agent only through the mount set:
every host path is mounted at its own absolute path, grants are bounded by
grant_roots
(the shipped default: $HOME), and a rw grant is read/write - that is the
point, so review each one when opencode asks. What the sandbox does not
protect (same-user trust boundary, self-prepared grants, no state outside
the mounts, ...) is listed, honestly, in the
Security model.
Getting started
Install the sandbox once, then configure each project. Every example below
uses placeholders (<host>, <user>, <uid>, $HOME) - substitute your
own values.
Install
Prerequisites: Linux on amd64 or arm64 with rootless podman (systemd
cgroup driver, the default on openSUSE and Fedora), Python 3.11+, and
OpenCode v2 (umwelt installs the v2 permissions policy). The base image
is built from host-architecture artifacts, so umwelt-ctl install works on
either architecture without emulation.
-
Install the package (prompts once for sudo):
uv tool install --index-url <forge-index> umwelt==<version> umwelt-ctl installOr from a checkout, with no index at all:
uv build && uv tool install --force dist/umwelt-*.whl umwelt-ctl installThe package is the tool and the image sources;
umwelt-ctl installwires OpenCode to theumwelt-mcpconsole script, copies the agent skill, and builds the sandbox image from the packaged Dockerfile. Upgrading isuv tool installagain plusumwelt-ctl refresh- no checkout, nogit pull.umwelt-ctl doctorreports what is missing or stale.It installs and enables the
umwelt-cgroup-delegate@<uid>.service, which delegates thememoryandcpucontrollers down the user cgroup hierarchy and re-applies that delegation at boot; builds the base images if missing; sets up the egress prerequisites (gateway image, CA/bundle, install state); installs umwelt's opencode integration (permission policy, helper plugins, globalAGENTS.md, and theumweltagent skill); and seeds the root-owned machine-local samples - a fully-commented OpenCode managed config (/etc/opencode/opencode.jsonc) and a commented host-actions drop-in sample (/etc/umwelt/host-actions.d/00-sample.toml) - each only when absent. The levels below your user slice (user-<uid>.slice/<user manager>/…) are not the unit's job:run.pyre-asserts them on every start, so a fresh login needs no service. If the delegation cannot be installed,run.pyrefuses to start and prints the manual fallback (needs sudo; lasts until reboot):sudo sh -c 'echo "+memory +cpu" >> /sys/fs/cgroup/user.slice/cgroup.subtree_control' sudo sh -c 'echo "+memory +cpu" >> /sys/fs/cgroup/user.slice/user-<uid>.slice/cgroup.subtree_control'The caps are a hard floor - see Security floor.
umwelt-ctl doctorreports the cgroup service, the images and egress state, and the opencode wiring;umwelt-ctl uninstallreverses the privileged install (again prompting for sudo).The image is the published
umwelt/Dockerfileasumwelt-base:latest, plus an optional machine-local layer from$HOME/.config/umwelt/image/(start fromumwelt/Dockerfile.overlay.example) - personal tooling needs no public commit and no edit to the tracked file.umwelt-ctl refreshrebuilds when either input changes. See Image and local overlay. -
Check that
~/.local/binis on your PATH (the package manager puts the console scripts there):command -v umwelt-ctl || export PATH="$HOME/.local/bin:$PATH" -
Wire it into opencode.
umwelt-ctl installmerges the globalmcp.servers.umweltblock, thepermissionsarray and its plugin entry (below) into the opencode config (it prefers~/.config/opencode/opencode.jsonc, creatingopencode.jsonif neither exists); it also seeds a commented/etc/opencode/opencode.jsonc, the highest-precedence managed layer (root-only, so users cannot override it) - inert until you uncomment keys.umwelt-ctl installowns and refreshes those blocks: the managedmcp.servers.umweltentry is replaced outright, while thepermissionsarray and plugin list are merged (your own rules and plugins are kept).external_directoryandumwelt_actionare yours to add. The MCP command is theumwelt-mcpconsole script, so it survives an upgrade:{ "mcp": { "servers": { "umwelt": { "type": "local", "command": ["python3", "~/.local/share/umwelt/src/umwelt/mcp_server.py"], "disabled": false, "codemode": false } } }, "permissions": [ { "action": "umwelt_shell", "resource": "*", "effect": "allow" }, { "action": "umwelt_list", "resource": "*", "effect": "allow" }, { "action": "umwelt_read_file", "resource": "*", "effect": "allow" }, { "action": "umwelt_read_image", "resource": "*", "effect": "allow" }, { "action": "umwelt_edit_file", "resource": "*", "effect": "allow" }, { "action": "umwelt_write_file", "resource": "*", "effect": "allow" }, { "action": "umwelt_copy_file", "resource": "*", "effect": "allow" }, { "action": "umwelt_note", "resource": "*", "effect": "allow" }, { "action": "umwelt_grant", "resource": "*", "effect": "ask" }, { "action": "umwelt_revoke", "resource": "*", "effect": "ask" }, { "action": "umwelt_egress", "resource": "*", "effect": "ask" }, { "action": "umwelt_ingress", "resource": "*", "effect": "ask" }, { "action": "read", "resource": "*", "effect": "deny" }, { "action": "edit", "resource": "*", "effect": "deny" }, { "action": "shell", "resource": "*", "effect": "deny" }, { "action": "glob", "resource": "*", "effect": "deny" }, { "action": "grep", "resource": "*", "effect": "deny" }, { "action": "list", "resource": "*", "effect": "deny" }, { "action": "websearch", "resource": "*", "effect": "deny" } ] }This is the OpenCode v2 shape: one global
permissionsarray (allow/ask/deny) applied to every agent - there is no per-agenttoolsmap any more, and thedenyentries on the built-in host tools replace the old "no direct host tools" agent.codemode: falsematters: code mode builds tool arguments with JS template literals, which mangle backticks and${...}before umwelt ever sees them.external_directoryandumwelt_actionare deliberately not managed (umwelt-ctl installcannot know where your repos live, andumwelt_actionfalls to OpenCode's default), so add them yourself if you want them governed - e.g. a host-specific entry:{ "action": "external_directory", "resource": "~/dev/**", "effect": "allow" }No per-project config is needed: each opencode instance's MCP server derives its project's sandbox from its CWD.
Upgrading from v1? Delete your old
"permission"map and theagent.umwelttools block - v2 uses the single globalpermissionsarray above and ignores both.umwelt-ctl installmerges the new array but does not prune the old keys, so leftovers would linger otherwise. -
Verify (with an OpenAI-compatible LLM server running):
cd ~/.local/share/umwelt/src python3 -m pytest umwelt/tests -v --ignore=umwelt/tests/test_integration.py # unit, fast python3 -m pytest umwelt/tests/test_integration.py -v # real podman, a few minutes python3 umwelt/ctl.py status # lists sandboxesThen start opencode in a project dir; the sandbox auto-launches (
umwelt-ctl statusshows it LIVE), andumwelt_shellreads and writes the workspace (at its own host path) freely.
Update an install
git -C ~/.local/share/umwelt/src fetch
git -C ~/.local/share/umwelt/src checkout <new-tag-or-sha>
umwelt-ctl update # local install: status, then install if stale
umwelt-ctl update --restart # ...and bounce running sandboxes onto it
The checkout alone is not an update: the TUI/server plugins and the agent
skill are copies refreshed at install time, so umwelt-ctl doctor reports the
skill STALE until the installer runs, and umwelt-ctl update is what reruns
it (it skips the rebuild when status is clean). Running sandboxes keep the
old code because run.py is a resident process - --restart stops the live
ones, and they relaunch on the next agent call. Image builds are skipped when
the inputs are unchanged, so a re-run is cheap.
Installing for another user on a headless host
umwelt is per-user: state lives in the user's $HOME
(~/.local/share/umwelt, ~/.config/umwelt), and the sandbox image
bakes the installing user's name/uid/$HOME. Install and update as that
user, not through sudo:
ssh -t <user>@<host>
uv tool install --index-url <forge-index> umwelt==<version>
umwelt-ctl install
ssh -tmatters: the one sudo step (cgroup delegation and the/etcsamples) prompts for a password, andumwelt-ctl installrefuses to run as root.- The TUI plugin is harmless over ssh; nothing needs skipping on a headless system.
If that user has no sudo, split the install at its only privileged boundary - an admin runs the root half, the user runs the rest:
# as the admin (from any account with sudo), while <user> is logged in
sudo ~/.local/bin/umwelt-ctl install root-prereqs <user>
# as <user>
umwelt-ctl install --skip-privileged
root-prereqs installs the cgroup delegation service for that uid and
the root-owned /etc samples, and prints the user-side command when it
is done. Run it while the user is logged in: the boot unit is a no-op
when the user's slice does not exist yet, and the command re-applies the
delegation for an existing slice. --skip-privileged installs
everything else (images, egress state, opencode wiring) and verifies the
delegation instead of installing it; if it is missing it names the
root-prereqs command to hand to the admin. The choice is recorded in
~/.local/share/umwelt/privileged-mode, so later installs - including
the one umwelt-ctl update runs - keep using it, and
umwelt-ctl doctor says so. Once the user has sudo,
umwelt-ctl install --privileged returns to the one-command path.
Removing the service likewise needs root: umwelt-ctl uninstall as the
user warns about that step and continues with the rest.
Configuration, when you need it
Configuration has three layers: the committed base umwelt/umwelt.toml (the
posture every sandbox gets - review it before building, it is your sandbox
policy), the machine-local overlay ~/.config/umwelt/umwelt.toml (or
$UMWELT_CONFIG; human-authored, never committed - machine tweaks stay out
of the public repo, and install.sh ships it as a commented sample), and
the project's gitignored .umwelt.toml (per-project, filled in by persisted
umwelt_grants and by umwelt_egress/umwelt_ingress). Machine-local
values use $HOME, expanded at load; the workspace itself is always mounted
read-write at its own host path. Merge rules (overlay wins for scalars,
appends to arrays of tables, never deletes base entries) are in
CONFIGURATION.md.
Each option below is off until the project opts in, and every widening goes
through an approval: the agent asks (the shipped opencode permissions keep
umwelt_grant, umwelt_revoke, umwelt_egress, and umwelt_ingress on
ask), you decide, and the action lands in the
workspace's audit log. The full rules for every option are in
CONFIGURATION.md.
Mounts
When you need it: the agent must see a host path the baseline does not carry - an ssh key to push with, a second project directory, more scratch.
# the project's .umwelt.toml (or ask at runtime with umwelt_grant)
[[mounts]] # a key file (file grants are read-only)
type = "bind"
source = "$HOME/.ssh/id_ed25519"
target = "$HOME/.ssh/id_ed25519"
read_only = true
[[mounts]] # a project dir the agent may write
type = "bind"
source = "$HOME/projects/myapp"
target = "$HOME/projects/myapp"
read_only = false
[[mounts]] # writable scratch (tmpfs, not on disk)
type = "tmpfs"
target = "/tmp/tools"
tmpfs_size = "512M"
What it enables: every mount uses identity mapping - the path inside
the container is the same absolute path as on the host, so host tools and
the sandbox never disagree. A source outside grant_roots (the shipped
default: $HOME) is rejected outright; a nested rw grant under a
read-only one works, with exactly the granted subtree writable.
Mounts and the
Host access model cover the full
rules.
Egress
When you need it: the agent needs the network - pip install (the base
rules already cover PyPI), an API, or git push.
# the project's .umwelt.toml
[egress]
enabled = true
[[egress.https]] # the whole host
host = "<git-host>"
port = 443
paths = ["*"]
[[egress.https]] # account-scoped path prefixes
host = "api.github.com"
port = 443
paths = ["/repos/<user>/*", "/users/<user>", "/rate_limit", "/graphql"]
[[egress.tcp]] # raw tcp (e.g. git push over ssh)
host = "<git-host>"
port = 22
What it enables: the sandbox joins a private per-workspace network and
may reach only what the policy allows, enforced twice - a static
kernel-enforced nftables ruleset (default-drop, and the sandbox has no
CAP_NET_ADMIN to change it) and a live gateway for HTTPS host+path and
raw TCP. Effective policy is the base rules unioned with the project's
rules (the workspace can only add); until enabled = true and every
prerequisite holds, the sandbox has no network at all - run.py refuses to
start otherwise, so there is no silent weakening. Every request is logged to
~/.local/share/umwelt/egress/audit-<id>.jsonl.
Egress covers the enforcement
layers, umwelt_egress, name resolution, and git/gh/fj in the sandbox.
Hosts
When you need it: a name your DNS will not serve - an internal server reachable only by IP.
# the project's .umwelt.toml
[[hosts]]
name = "<server-hostname>"
address = "<server-ip>"
What it enables: a static line in the container's /etc/hosts (via
--add-host), applied on the next control-plane start or container
recreate. A hosts entry makes a name resolvable but does not widen the
egress policy - a connection to that IP still needs an [[egress.tcp]]
rule, as in the example above. Hosts entries and name
resolution.
Ingress
When you need it: you want your host browser to open a dev server that runs inside the sandbox.
# the project's .umwelt.toml
[ingress]
enabled = true
ports = [5173]
What it enables: the listed container ports published on the host
loopback only - http://127.0.0.1:5173, never the LAN - at the same port
number (no remapping). The server inside the sandbox must listen on
0.0.0.0, not 127.0.0.1 - for Vite: npm run dev -- --host. Ingress is
independent of egress: it grants no outbound access.
Ingress.
Host actions
When you need it: the agent edits a host service's configuration and needs to validate or reload it - e.g. an nginx drop-in.
install.sh install seeds /etc/umwelt/host-actions.d/00-sample.toml as a
fully-commented sample (never overwriting your drop-ins); what follows is one
filled-in drop-in. Every *.toml in that directory is merged in filename
order into one policy, so split actions per cluster/project and keep each
name unique across the directory. The legacy single file
/etc/umwelt/host-actions.toml is read only when the directory does not
exist; having both is an error.
# /etc/umwelt/host-actions.d/20-cluster-a.toml - root-owned; never in the repo,
# never in the agent-writable workspace
[[action]]
name = "nginx-test"
description = "Run `nginx -t` to validate the running configuration."
privilege = "root"
command = ["/usr/local/sbin/umwelt-nginx-test"]
timeout = 30
[[action]]
name = "nginx-reload"
description = "Validate the nginx configuration, then reload the service."
privilege = "root"
command = ["/usr/local/sbin/umwelt-nginx-reload"]
After editing: user-privilege actions apply on the next umwelt_action
call (the policy is read fresh each time); for privilege = "root" actions
re-run sudo umwelt/host-actions.sh install to regenerate the NOPASSWD
allowlist (it also applies any [[read]] ACLs).
What it enables: the agent may request one of these fixed-argv host
commands at a time (umwelt_action: no shell, no free-form arguments);
privilege = "root" runs through a generated NOPASSWD sudoers file,
"user" directly. Every call is ask-gated, timeout-bounded, output-capped,
and audited - in the nginx loop, the write (a rw grant, ask) and the
reload (ask) are separate approvals. The ask is UX, not the boundary:
the root-owned policy is.
Host actions.
Remote targets
When you need it: you want the agent to drive a umwelt sandbox on another host, from the same session.
# $HOME/.config/umwelt/remotes.toml - your machine, never the repo
[[server]]
name = "app-server"
ssh_host = "<server-host>"
identity = "$HOME/.ssh/umwelt_remote"
# the project's .umwelt.toml - the allowlist for this project
[[remote]]
name = "app-server"
workspace = "/srv/app"
Provisioning the server. Before host = "app-server" can reach it,
set the server up once, from your existing admin ssh access:
umwelt-ctl remote-install app-server <server-host>
Add --egress to provision the server's opt-in egress prerequisites (the
gateway image, the CA bundle, and the install manifest) and record the
[egress] floor in the server's base config; without it the server
installs nothing network-related. Egress is a server-floor opt-in, not an
enabled policy - the project still turns it on.
That preflights the host (Linux; rootless podman or an installable
podman+uidmap - there is no Docker fallback), pushes the reduced
install tree to $HOME/.local/share/umwelt/remote on the server, runs the
on-server install (podman if missing, cgroup delegation, the base image,
the server's own umwelt.toml floor), adds one authorized_keys line
for a dedicated restricted key ($HOME/.ssh/umwelt_remote, whose public
half is the only thing that leaves your machine; the line is a
forced-command relay with forwarding denied, tagged umwelt-remote:<name>),
writes the registry entry, and verifies the whole chain.
umwelt-ctl remote-status app-server is the end-to-end check: it opens
that restricted channel - the same one an agent uses - and reports the
server's floor and relay protocol, or exits non-zero naming the
remote-install remedy when the credential/relay is missing.
umwelt-ctl remote-remove app-server revokes it: it drops only the tagged
line (your other keys survive) and the registry entry, never the server's
install directory. Provisioning a
server for the details, including
the key line and its hygiene.
What it enables: pass host = "app-server" to the acting tools
(shell, grant, revoke, read_file, read_image, write_file,
edit_file, action, list, egress) and the call runs in that
server's sandbox
over ssh (forced command umwelt-relay), instead of the local one; omit
host and nothing changes. The server's own floor still applies: the
requested
workspace must be under the server's workspace_roots, and grants under
its grant_roots. ingress and note are local-only and refused with
host=; egress is routable too - its rule target is the destination
argument, so host selects the sandbox and is never the policy target.
One ssh channel is reused per server and
closed when the agent exits, and every remote call is audited locally.
Remote targets.
Where to go deeper
- CONFIGURATION.md - everything in depth: configuration layers and precedence, mounts, the security model, egress, ingress, host actions, remote targets, the control plane and its audit log, usage (all MCP tools, the CLI, agent notes), opencode integration (permission popups, context-pruning plugins), agent skills, and development.
- AGENTS.md - agent-facing notes (repo hygiene, quick model).
- Design specs under
docs/specs/(binding decisions); implementation plans underdocs/plans/.
License
umwelt is dedicated to the public domain under CC0 1.0 Universal. To the extent possible under law, the authors have waived all copyright and related or neighboring rights to this work. No attribution is required; the full legal code is in LICENSE.
CC0 grants no patent or trademark rights (section 4(a) of the legal code).