Let your LLM agents loose. Securely. https://verified.technology
  • Python 89%
  • Shell 9.2%
  • TypeScript 1%
  • Dockerfile 0.6%
  • JavaScript 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
troed 89aacc0f99
All checks were successful
ci / unit (push) Successful in 1m19s
ci / publish (push) Successful in 10s
fix: never let an umwelt child inherit the JSON-RPC stdin (#14)
Follow-up to #13 (merged as `0b3b77c`). #13 fixed a real problem — a serialised front door — but it did **not** fix the hangs users were still seeing. This does.

## Problem

umwelt's children inherited the MCP server's stdin.

`podman()` called `subprocess.run(..., input=stdin)` with `stdin=None`. That does not mean "no input" — it means **inherit fd 0**, and fd 0 of the MCP server is the JSON-RPC pipe from opencode. Six call sites then run `podman exec -i`, which keeps stdin attached and forwards it into the container **for the whole call**. So while a `read_file`, `edit_file`, `read_image` or `read_chunk` exec is alive, podman is reading the same pipe as the front door's reader. A request line that arrives in that window is swallowed by podman and never reaches the server: no response, ever. The client waits, the harness shows nothing, and the tool part stays `running` indefinitely.

It needs a concurrent sender to leave a request pending while a `-i` exec is alive — which is exactly why it always hurt **more with subagents**. It predates #13; #13's concurrency widened the window rather than creating the bug.

## Evidence

- `/home/troed/dev/onmymind`, session `ses_e481de92…`: an `umwelt_edit_file` on `src/plugin.ts` issued at 15:10:33 was still `running` at 15:18, with no client-side transport error. Its three siblings from the same message completed.
- The opencode log contains `Invalid MCP stdio message from umwelt … "id": null … Unrecognized key: "error"` — one of the server's parse-error frames, which can only occur when a *split* line reaches the reader. A line split in two is what a reader stealing from the middle of the pipe produces.
- `podman exec -i` consuming the caller's stdin is long-standing, documented behaviour (the classic `while read line; do podman exec -i …; done` that skips every other line).

## Fix

- `podman()` passes `stdin=subprocess.DEVNULL` whenever no payload is supplied — no child inherits fd 0. An explicit payload still goes in as a pipe.
- `-i` dropped from the four exec sites that never feed stdin (`edit_file`, `read_file`, `read_image`, `read_chunk`); kept for `write_file` / `write_chunk`, which do.
- Same treatment for the host-side `git` and `keytool` children, and for the `run.py` launch itself, which is the control plane every sandbox child inherits from.

## Tests

- New: `test_podman_never_inherits_the_servers_stdin` — pins `stdin=DEVNULL` for the no-payload path and `input=` for the payload path.
- Four existing argv assertions inverted: reads and edits must not pass `-i`.
- Suite: `python3 -m pytest -m "not integration and not root and not mitmproxy" -q` → **1312 passed**, 44 deselected (8 warnings pre-existing).

## After merge

Needs a rebuild + `uv tool install --force` and an opencode restart; a session that is already wedged keeps its stuck call until it restarts.

Co-authored-by: Troed Sångberg <github@troed.se>
Reviewed-on: #14
2026-11-08 16:28:07 +01:00
.forgejo/workflows ci: publish from the CI workflow, gated on the unit job 2026-11-03 13:30:34 +00:00
docs fix(mcp): serve tools/call concurrently so a long shell cannot starve file ops (#13) 2026-11-08 14:59:40 +01:00
umwelt fix: never let an umwelt child inherit the JSON-RPC stdin (#14) 2026-11-08 16:28:07 +01:00
.gitignore fix: review fixes — the version moves, the server gets its config 2026-11-03 13:10:17 +00:00
AGENTS.md fix(egress,ctl): survive a dead hostname, name the right workspace (#11) 2026-10-28 22:32:31 +01:00
CONFIGURATION.md feat: multi-user support - root-prereqs CLI + package-era docs (#12) 2026-11-07 20:48:13 +01:00
icon.png hello world 2026-09-15 17:28:53 +02:00
LICENSE chore: dedicate umwelt to the public domain under CC0 1.0 2026-10-13 14:00:32 +00:00
pyproject.toml test(pkg): every tracked tree file ships in the wheel 2026-11-03 11:59:41 +00:00
pytest.ini test: make the suite honest about what it covers 2026-10-15 17:30:50 +00:00
README.md feat: multi-user support - root-prereqs CLI + package-era docs (#12) 2026-11-07 20:48:13 +01:00

umwelt

umwelt

License: CC0 1.0

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. initialize succeeds even when the control plane is down; the first tool call lazily (re)attaches or relaunches run.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.py exits 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.

  1. Install the package (prompts once for sudo):

    uv tool install --index-url <forge-index> umwelt==<version>
    umwelt-ctl install
    

    Or from a checkout, with no index at all:

    uv build && uv tool install --force dist/umwelt-*.whl
    umwelt-ctl install
    

    The package is the tool and the image sources; umwelt-ctl install wires OpenCode to the umwelt-mcp console script, copies the agent skill, and builds the sandbox image from the packaged Dockerfile. Upgrading is uv tool install again plus umwelt-ctl refresh - no checkout, no git pull. umwelt-ctl doctor reports what is missing or stale.

    It installs and enables the umwelt-cgroup-delegate@<uid>.service, which delegates the memory and cpu controllers 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, global AGENTS.md, and the umwelt agent 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.py re-asserts them on every start, so a fresh login needs no service. If the delegation cannot be installed, run.py refuses 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 doctor reports the cgroup service, the images and egress state, and the opencode wiring; umwelt-ctl uninstall reverses the privileged install (again prompting for sudo).

    The image is the published umwelt/Dockerfile as umwelt-base:latest, plus an optional machine-local layer from $HOME/.config/umwelt/image/ (start from umwelt/Dockerfile.overlay.example) - personal tooling needs no public commit and no edit to the tracked file. umwelt-ctl refresh rebuilds when either input changes. See Image and local overlay.

  2. Check that ~/.local/bin is on your PATH (the package manager puts the console scripts there):

    command -v umwelt-ctl || export PATH="$HOME/.local/bin:$PATH"
    
  3. Wire it into opencode. umwelt-ctl install merges the global mcp.servers.umwelt block, the permissions array and its plugin entry (below) into the opencode config (it prefers ~/.config/opencode/opencode.jsonc, creating opencode.json if 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 install owns and refreshes those blocks: the managed mcp.servers.umwelt entry is replaced outright, while the permissions array and plugin list are merged (your own rules and plugins are kept). external_directory and umwelt_action are yours to add. The MCP command is the umwelt-mcp console 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 permissions array (allow/ask/deny) applied to every agent - there is no per-agent tools map any more, and the deny entries on the built-in host tools replace the old "no direct host tools" agent. codemode: false matters: code mode builds tool arguments with JS template literals, which mangle backticks and ${...} before umwelt ever sees them. external_directory and umwelt_action are deliberately not managed (umwelt-ctl install cannot know where your repos live, and umwelt_action falls 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 the agent.umwelt tools block - v2 uses the single global permissions array above and ignores both. umwelt-ctl install merges the new array but does not prune the old keys, so leftovers would linger otherwise.

  4. 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 sandboxes
    

    Then start opencode in a project dir; the sandbox auto-launches (umwelt-ctl status shows it LIVE), and umwelt_shell reads 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 -t matters: the one sudo step (cgroup delegation and the /etc samples) prompts for a password, and umwelt-ctl install refuses 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

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).