- Python 95.8%
- Shell 3.3%
- Dockerfile 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| docs | ||
| umwelt | ||
| .gitignore | ||
| AGENTS.md | ||
| icon.png | ||
| README.md | ||
| umwelt-tooling-feedback.md | ||
umwelt
A hardened podman sandbox for LLM agents. One sandboxed container per project workspace; the agent's shell commands run inside it. The sandbox is sealed against the host by default: host paths appear inside only when explicitly granted, and the network is off entirely unless the project opts in - in which case its access is curated to an explicit policy, enforced in the sandbox's own network namespace by the kernel and by a trusted gateway it cannot alter.
umwelt is model- and LLM-agnostic: 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). The container is named
umwelt-<sid>and the control plane serves a JSON-line API on$XDG_RUNTIME_DIR/umwelt-<sid>.sock(mode 0600). The same workspace always gets the same sandbox, from any agent or session. - Attach vs launch. The MCP server attaches to the workspace's control
plane 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: it detects the live control plane, exits cleanly, and never touches the winner's socket. - 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 (see below).
Security model
The floor
Flags hardcoded in run.py, 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. This is the default for every sandbox. |
--read-only |
Read-only root filesystem. The agent cannot plant files in the image, tamper with system binaries, or persist outside the workspace. |
--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. They are config-derived (review them in the base config), but they
cannot be omitted: applying them requires cgroup delegation, and without it
run.py refuses to start - there is no silent weakening path.
Host prerequisite (one-time, needs sudo). Delegate the memory and cpu
controllers down the user cgroup hierarchy (replace <uid> with your uid):
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'
This lasts until reboot; persist it with a /etc/tmpfiles.d entry and a
user@.service drop-in if you want it across reboots. run.py additionally
self-heals the user-owned levels below your slice (e.g.
user@<uid>.service, app.slice) automatically and verifies delegation
reached your session before starting.
Host access model
The container sees the host only through the mount set. Host paths are mounted at their own absolute paths (identity mapping): whatever a host-side tool calls a path, the sandbox calls it the same thing, so host tools and the sandbox never disagree on a path:
- Baseline (committed
umwelt/umwelt.toml): the workspace mounted read-write at its own host path, plus the base config's reference mounts (the shipped default binds$HOME/devread-only at$HOME/devand$HOME/.configread-only at$HOME/.config, and a 512 MB tmpfs at/tmp/tools). All base mounts are read-only except the workspace. - Grants. The agent can request
roorrwaccess to a host path, and optionally persist it. Grants are bounded by thegrant_rootsallowlist in the base config (the shipped default:$HOME) - a grant outside it is rejected outright. A session grant lives in memory until the control plane exits; a persisted grant is written to the project's.umwelt.toml(gitignored) and survives restarts. Precedence when several layers name the same path: session > persisted > base. - Shadowing. A nested
rwgrant under a read-only grant works (parent mounts are ordered first); the readable/writable subtree is exactly what was granted. - Self-prepared grants.
.umwelt.tomllives inside the workspace, which the agent can write - so the agent can edit its own grant file directly. Everyumwelt_shellresult is checked against the previous digest of that file: if the command modified it, the same tool result carriesNOTE: .umwelt.toml was modified by that command: <mount list>; persistedumwelt_grant/umwelt_revokeresults carry equivalent notices. None of this changes the running container - the control plane keeps its in-memory mount snapshot, and even a container recreate uses that snapshot, not the file. File edits take effect only when the control plane process restarts (the notices say "Takes effect on next sandbox restart"), and the snapshot is authoritative for the file: a later persisted grant rewrites.umwelt.tomlfrom it, discarding hand edits not in the snapshot. Review the notices, and inspect.umwelt.tomlin the project after a session (gitignored, but on disk). - Environment isolation. Shell execution runs inside the container with the container's environment; no host environment values are interpolated in.
- Control plane. The socket API is 0600 inside the 0700
$XDG_RUNTIME_DIR, and is never mounted into the container. Theshutdownmethod is deliberately not exposed over MCP - stopping a sandbox is a human action viaumwelt-ctl.
What the sandbox does not protect
Honest limits, so they are surprises for no one:
- A
rwgrant is read/write. That is the point. Review eachrwgrant when opencode asks (the shipped MCP permissions keepgrant/revokeonask), and userowhere reading suffices. - The agent can prepare its own persisted grants by editing
.umwelt.toml(it sits in the rw workspace). The edit is surfaced as a notice in the shell result and takes effect only on the next control-plane restart - see "Self-prepared grants" above; inspect the file after a session. - Same-user trust boundary. The sandbox separates the agent's processes from the host, not one user from another. The socket's 0600-in-0700 mode stops other local users, not the owner.
- Simultaneous start race. If two agents in the same project start at the same instant, the loser's control plane still recreates the shared container before it detects the winner and exits cleanly - in-flight state in the container is lost in that window. Steady state is one control plane per workspace.
- Read-only rootfs means no state outside the workspace, the tmpfs, and granted paths. Tools must ship in the image or be granted from the host.
- No network by default means no online package installs. Grant a host package cache (ro) and a writable staging path if the agent needs them; or opt the project into egress (the base rules already cover PyPI).
- SELinux trade-off. On enforcing hosts the container runs with
label=disableso the bind grants work; the flags in the floor above are what keep the posture hard.
Egress (opt-in per project)
By default a sandbox has no network at all (--network=none). A project can
opt into curated egress: its sandbox joins a private per-workspace podman
network and may reach only what the policy allows. Two layers enforce it:
- Kernel (static, per container).
run.pyrenders an nftables ruleset (default-drop output; allow loopback, the network's DNS resolver, the host's detected upstream DNS servers, the gateway's proxy ports, and each static tcp target) into an OCIcreateContainerhook that podman runs in the sandbox's own network namespace. The sandbox has noCAP_NET_ADMINand nonftbinary, so it cannot alter the rules; a hook failure aborts container creation (fail-closed). The ruleset is fixed at create time. - Gateway (live policy). A trusted per-workspace container
(
umwelt-egress-gw-<id>) runs mitmproxy for HTTPS host+path policy and a SOCKS5 relay for raw TCP. Both re-read the policy file on change, so policy edits apply live.
Name resolution in an egress sandbox: its only nameserver is the
network's resolver (podman's aardvark-dns), which serves local podman names
and forwards external queries to the host's upstream resolver(s) as detected
at start (run.py bakes them into the network's DNS config). [[hosts]]
entries add static name -> IP lines to the container's /etc/hosts
(Hosts entries and name resolution).
Egress is used only when the effective config has an [egress] table with
enabled = true
and every prerequisite holds (install state present and schema-current, the
gateway image and CA bundle present, host nft present, podman network create usable). Otherwise run.py refuses to start with an error naming
umwelt/install.sh install - there is no silent weakening.
Configuration
The committed base umwelt/umwelt.toml carries [egress] with reference rules
and never sets enabled itself:
[egress]
[[egress.https]]
host = "pypi.org"
port = 443
paths = ["*"]
[[egress.https]]
host = "files.pythonhosted.org"
port = 443
paths = ["*"]
A project turns egress on and adds its own rules in the gitignored
.umwelt.toml:
[egress]
enabled = true
[[egress.https]]
host = "<forgejo-host>"
port = 443
paths = ["*"] # the whole instance
[[egress.https]]
host = "github.com"
port = 443
paths = ["/<user>/*"] # account-scoped
[[egress.https]]
host = "api.github.com"
port = 443
paths = ["/repos/<user>/*", "/users/<user>", "/rate_limit", "/graphql"]
[[egress.tcp]] # raw tcp: forgejo ssh, deployment ssh
host = "<forgejo-host>"
port = 2221
[[egress.tcp]]
host = "<deploy-server>"
port = 22
[[hosts]] # static /etc/hosts entry (no policy change)
name = "<forgejo-host>"
address = "<server-ip>"
pathsis a prefix list;"*"(the default when omitted) matches every path. Path policy applies to HTTPS (TLS is terminated at the gateway).- Effective policy = base rules ∪ workspace rules (union). The workspace can only add; base rules cannot be removed.
[[egress.tcp]]hostnames are resolved host-side to IPv4 only when the sandbox is created (the kernel ruleset isip daddr); a host with no IPv4 address is refused at gateway start, and a name whose address changes needs a re-add or a restart.
Hosts entries and name resolution
.umwelt.toml (or the base config) can also carry [[hosts]] entries; each
is written to the container's /etc/hosts via --add-host:
[[hosts]]
name = "<server-hostname>"
address = "<server-ip>"
addressis a literal IP (v4 or v6); no hostname indirection.- Entries are static: they apply on the next control-plane start or container recreate.
- A hosts entry makes a name resolvable; it does not widen the egress
policy - a connection to that IP still needs an
[[egress.tcp]]rule.
For name resolution in general: an egress sandbox's only nameserver is the
network resolver (podman's aardvark-dns). The host's upstream resolver(s)
as detected at start (run.py follows the systemd-resolved 127.0.0.53
stub to the real upstreams) are baked into the per-workspace network's DNS
config, so the network resolver forwards external queries to them; local
podman names keep resolving through it. If the host exposes no usable
upstream, run.py starts with a warning and external names do not
resolve; mDNS (.local) names need the host-side bridge described in the
DNS design doc.
umwelt_egress
The agent manages the policy with one ask-gated tool; a human approves every widening:
umwelt_egress {op: "add", kind: "https"|"tcp", host, port, paths?}umwelt_egress {op: "remove", kind, host, port}(workspace rules only)umwelt_egress {op: "list"}
add/remove persist to .umwelt.toml and apply live where the kernel
permits it. list reports the effective policy, the gateway state, and each
tcp rule's delivery mode:
direct- the rule existed when the sandbox container was created, so it is in the static kernel ruleset and works for any client (no proxy).relayed- the rule was added after creation, so it is served through the gateway's SOCKS5 relay. Clients that speak SOCKS5 or honorALL_PROXYwork immediately; others need a sandbox restart to pick up a direct rule.pending_removalreports direct rules removed from the policy that the running kernel ruleset still permits until the sandbox is recreated.
Audit
The gateway appends one JSON line per request/connection to
~/.local/share/umwelt/egress/audit-<id>.jsonl. The file is host-owned and
never writable from the sandbox. The gateway reads the policy from its
read-only /egress mount (so a live policy change is picked up without a
restart) and gets the audit file mounted read-write at /audit.jsonl so it
can append:
{"type": "umwelt-egress", "ts": 0, "workspace": "<id>", "kind": "https",
"method": "GET", "host": "<forgejo-host>", "port": 443,
"path": "/<user>/repo", "verdict": "allow"}
Static direct tcp has no gateway in the path: drops are logged to the kernel
log (umwelt-tcp-drop, rate-limited) and allowed connections are visible as
plain 5-tuples.
Git and gh in-sandbox
- https remotes work through the gateway proxy (the per-exec environment
injects
HTTPS_PROXY/HTTP_PROXY/ALL_PROXY/NO_PROXYandSSL_CERT_FILE, which points at the CA bundle mounted at/etc/ssl/certs/umwelt-ca.pem). To rewrite an existing ssh remote to https for a host, set a repo-localinsteadOf(e.g.git config url."https://<forgejo-host>/".insteadOf "git@<forgejo-host>:") - user-side configuration, not sandbox behavior. ghreads its token from~/.config/gh, which the base mounts already expose read-only at its own path. No code, no extra grant.git pushover ssh needs an[[egress.tcp]]rule for the git host (direct when configured before the sandbox is created, relayed/restart otherwise) plus a granted key. Grant the key as a single file read-only:umwelt_grantwithfile: true(file grants are ro only), which exposes just that key rather than all of~/.ssh. The hygiene pre-push hook fires in-sandbox becausecore.hooksPathis injected on every exec and~/.githooksis already mounted read-only.
Post-install checklist (host)
bash umwelt/install.sh install- builds/refreshes the gateway imageumwelt-egress-gw:latest, generates the CA/bundle/confdir, writesinstall.json, and verifies hostnft+podman network create. No sudo. (If an earlier revision had you install rev-A prerequisites, remove them: delete the host mitmproxy venv dir and any hostinet umweltnftables table. A2 uses neither.)bash umwelt/build.shonly if the baseumwelt:latestimage is stale (egress changesrun.py, not the base image).- Restart the control plane for the target workspace
(
python3 umwelt/ctl.py stop <workspace>; the next opencode session auto-launches it) so a newrun.pyor edited config loads.umwelt_egresschanges apply live without this. - Merge
"umwelt_egress": "ask"into~/.config/opencode/opencode.jsonc. - Per project that wants egress: set
enabled = trueunder the[egress]table in.umwelt.tomlplus rules (or add them at runtime withumwelt_egress), then restart that workspace's control plane. - Run the integration suite on the host:
python3 -m pytest umwelt/tests/test_integration.py -v. - Push when ready - from the host, or in-sandbox
git pushonce a tcp rule and a granted key exist for the git host. Pushing is your decision.
Installation
Prerequisites: Linux with rootless podman (systemd cgroup driver, the default on openSUSE and Fedora), Python 3.11+.
-
One-time cgroup delegation (needs sudo; above). Without it
run.pyrefuses to start and prints these exact commands. -
Build the image (once; it contains the tooling the agent gets):
git clone <repo-url> ~/dev/umwelt bash ~/dev/umwelt/umwelt/build.sh # builds umwelt:latest -
Optional - egress prerequisites (only if some project will opt into network egress; the gateway image is separate from
umwelt:latest):bash ~/dev/umwelt/umwelt/install.sh install # build umwelt-egress-gw:latest, # generate the CA + bundle, # write install.json bash ~/dev/umwelt/umwelt/install.sh status # image, CA, install.json, host nft, network bash ~/dev/umwelt/umwelt/install.sh remove # delete egress state (image: podman rmi)No elevated privileges: there is no host firewall table and no host mitmproxy venv (the sandbox netns ruleset is installed per container by podman's
createContainerhook). See the Egress section and the post-install checklist. -
Optional - put the CLI on your PATH:
export PATH="$HOME/dev/umwelt/umwelt/bin:$PATH" # gives you `umwelt-ctl` -
Verify:
cd ~/dev/umwelt 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 sandboxes
opencode integration
Merge the following into ~/.config/opencode/opencode.jsonc (merge, don't
replace; adjust key style to the file's existing conventions). The command
path assumes the repo is checked out at ~/dev/umwelt - substitute your own
checkout path:
{
"mcp": {
"umwelt": {
"type": "local",
"command": ["python3", "~/dev/umwelt/umwelt/mcp_server.py"],
"enabled": true
}
},
"permission": {
"umwelt_shell": "allow",
"umwelt_list": "allow",
"umwelt_grant": "ask",
"umwelt_revoke": "ask",
"umwelt_egress": "ask"
},
"agent": {
"umwelt": {
"tools": {
"read": false, "edit": false, "write": false, "bash": false,
"glob": false, "grep": false, "list": false, "task": false,
"question": false, "todowrite": false, "webfetch": false,
"websearch": false, "lsp": false, "skill": false, "compress": false
}
}
}
}
The dedicated umwelt agent has no direct host tools - every host interaction
goes through the sandbox. No per-project config is needed: each opencode
instance's MCP server derives its project's sandbox from its CWD.
With the config in place and an OpenAI-compatible LLM server running, a quick manual check:
- Start opencode in a project dir with the
umweltagent; the sandbox auto-launches (umwelt-ctl statusshows it LIVE). umwelt_shellreads and writes in the workspace (at its own host path) freely.umwelt_granton a new dir prompts you; approve and the access works (persisted grants also appear in.umwelt.toml), reject and nothing changes.- A second opencode in a different project runs concurrently; grants in one don't touch the other; killing one opencode kills only its sandbox.
Usage
MCP tools (as seen by the agent):
umwelt_shell- run a command inside the sandbox (CWD is the workspace).umwelt_grant- requestro/rwaccess to a host path, optionally persisted. Single files are accepted withfile: trueand are always read-only.umwelt_revoke- remove a grant.umwelt_list- list the sandbox's current mounts.umwelt_egress- add/remove/list egress rules (op,kindhttps/tcp,host,port,paths); ask-gated; persists to.umwelt.tomland takes effect live (see Egress).
CLI (human-facing):
umwelt-ctl status # live/dead sandboxes for this host
umwelt-ctl stop <workspace-or-id> # graceful stop (socket shutdown)
umwelt-ctl stop <workspace-or-id> --hard # podman rm -f, for dead control planes
python3 umwelt/ctl.py ... works without the PATH entry.
Configuration. Two layers:
umwelt/umwelt.toml- the committed base posture: image, session prefix, fallback workspace, thegrant_rootsallowlist,inject_git_identity,[resources]caps, base[[mounts]], and the base[egress]rules (which never enable egress themselves). Machine-local values use$HOME, expanded at load. Review this file before building; it is your sandbox policy.<workspace>/.umwelt.toml- per-project, gitignored. Created by persisted grants andumwelt_egress; holds the project's[egress] enabledflag and rules. The workspace's session mounts always take precedence over it.
Precedence for a path claimed by several layers: session > persisted > base; the workspace itself is always present and read-write. Egress is the union of base and workspace rules (see Egress).
Development
umwelt/
run.py control plane (config, mounts, container, socket API, watchdog)
client.py shared socket client + session identity
mcp_server.py MCP server + sandbox launcher
ctl.py the umwelt-ctl CLI
bin/umwelt-ctl thin PATH wrapper
umwelt.toml committed base config
build.sh builds the image (umwelt:latest)
install.sh egress prerequisites (gateway image, CA/bundle, install state)
Dockerfile the sandbox image
egress-gw/ the egress gateway image (mitmproxy + SOCKS5 relay)
egress_policy.py egress policy model, ruleset rendering, env
egress_addon.py mitmproxy policy addon (HTTPS)
egress_socks5.py raw-TCP SOCKS5 relay
tests/ unit tests + test_integration.py (real podman)
docs/specs/ the design spec (binding decisions)
docs/plans/ the implementation plan
AGENTS.md agent-facing notes (repo hygiene, quick model)
- Unit tests (fast, no podman):
python3 -m pytest umwelt/tests -v --ignore=umwelt/tests/test_integration.py. - Integration tests (real containers, a few minutes; need the cgroup
delegation):
python3 -m pytest umwelt/tests/test_integration.py -v. They skip with the exact sudo fix if the delegation is missing. - Hygiene. This repo is public: tracked files never contain hostnames,
LAN IPs, usernames, or home paths - see
AGENTS.mdfor the rules and thehygiene_scan_treemanual scan.
Further reading
- Design spec (binding decisions):
docs/specs/2026-09-15-umwelt-sandbox-design.md - Egress design spec (binding decisions, rev A2):
docs/specs/2026-09-16-umwelt-egress-design.md - Implementation plan (task-by-task, with the full code):
docs/plans/2026-09-15-umwelt-sandbox.md - Egress implementation plan:
docs/plans/2026-09-16-umwelt-egress.md - Agent-facing notes:
AGENTS.md