Start here
Linux tutorial
Install Warden on Linux, run a first Bubblewrap workload, inspect MIR and receipts, then add mediated networking.This tutorial follows one Linux machine from its first confined run all the
way to mediated networking, stopping on every artifact produced along the way.
The quickstart gets a first confined command running in a few minutes. This tutorial goes further: you read the source JSON policy, the compiled MIR and the execution receipt, then wire mediated networking end to end through both of its frontends.
Prerequisites
To run Warden
| What | Requirement | When |
|---|---|---|
| System | Linux amd64 or arm64 | always |
| Confinement | Bubblewrap, with unprivileged user namespaces allowed by the kernel | always |
| OCI targets | none on a Linux host | not available |
| Policy tooling | maelys-policy, from the separate maelys-sandbox-policy package | optional |
brew install maelys-dev/tap/maelys-warden gives you everything needed to compile
a policy and run a command under it. The policy compiler and Egress, the
network enforcement engine, are static libraries linked into the maelys-warden
binary: nothing else to install, no daemon to start. Egress still only acts when a
policy asks for mediated networking — under the default network: none, no
Egress, no relay and no fd 4 are ever created.
maelys-policy is the one thing that does not come with Warden. It is the CLI of
the policy product, useful only to inspect compiled MIR;
policy.maelys.dev covers its installation.
The OCI row surprises people, because libkrun is a Linux project and runs natively there. The limit is Warden's, not libkrun's: the Krun backend is compiled on every platform, but its host probe returns unsupported unless it is running on Apple Silicon, so the candidate is never selectable on Linux. The microVM backends exist to give macOS an isolation boundary it does not otherwise have. On Linux that boundary already exists, and it is the second row.
To build from source
| What | Requirement |
|---|---|
| Compiler | any C11 toolchain |
maelys-system | 0.4.0, on the exact pinned commit, ABI 1 — required |
maelys-sandbox-policy | 0.4.0 — optional, widens the checks |
maelys-egress | 0.9.0 — optional, widens the checks |
maelys-system is the one you cannot skip: Warden links every library object
against it, and the build refuses a checkout that is not on the pinned commit.
Egress is built against the same contract, which is why a single version governs
both. None of the three is a runtime dependency of an installed CLI.
Bubblewrap is the program that does the real confining work on Linux. It builds a restricted view of the machine — a private mount tree, a private network namespace, a confined process tree — and starts your command inside it. Warden drives it, and never asks it for authority your policy did not request.
Install Warden
Install a released binary
Linux amd64 or arm64
Every method installs the same binaries.
The runtime package; add libmaelys-warden-dev only if you build against the C API.
curl -LO https://github.com/maelys-dev/maelys-warden/releases/download/v0.41.0/maelys-warden_0.41.0_amd64.deb
sudo apt install ./maelys-warden_0.41.0_amd64.deb
maelys-warden --versionAlways verify a downloaded artifact before installing it: see release integrity.
Install from source
Only if you want to run the repository test suite, or read and patch what you run. It is not required to follow the rest of this tutorial.
Linux amd64 or arm64
Every method installs the same binaries.
Build the checkout yourself, to read or patch what you run.
git clone https://github.com/maelys-dev/maelys-warden
cd maelys-warden
make check
make test-bubblewrap
make cli-e2e
sudo make install install-cliA read-only workspace
maelys-warden run --ro --workspace "$PWD" -- sh -c 'cat README.md'
maelys-warden run --ro --workspace "$PWD" -- sh -c 'echo x > denied'The first -- separates the Maelys options from the command. sh, -c and the
final string are three arguments of the workload. The second command must fail
without creating denied.
Nothing in those two lines names a sandbox, yet both runs are confined. Warden
compiled --ro --workspace "$PWD" into a decision, then looked for a
backend able to enforce it: the concrete operating-system mechanism that
actually blocks the forbidden write. On this host that backend is Bubblewrap.
Warden always picks the strongest eligible backend rather than the most convenient one, and it refuses to start when no available mechanism can enforce the decision. It never quietly downgrades to a weaker backend, so a run that starts is a run whose rules are really in force.
From JSON policy to MIR to receipt
maelys-warden run \
--policy examples/policies/workspace-rw.json \
--emit-mir /tmp/workspace-rw.mir \
--receipt /tmp/build.receipt.json \
-- make testThat single command performs, in order:
- reads and validates
workspace-rw.json; - compiles it into canonical MIR v2, in memory;
- writes exactly those bytes to
/tmp/workspace-rw.mir, atomically and with mode 0600; - resolves
workspaceto the current directory, because--workspaceis absent; - compiles the MIR into a Bubblewrap plan;
- starts the workload with
argv = {"make", "test"}; - waits for it to finish;
- writes
/tmp/build.receipt.json, atomically and with mode 0600.
Steps 2 and 5 are the two translations worth understanding. The first turns your policy into a decision. The second turns that decision into a configuration for one particular backend.
The MIR it produced
MIR is that decision in canonical form: what Warden actually decided, once
the source JSON was read, validated and normalized. It is not the input
language, and it is not a second configuration file for you to edit — you never
hand-author MIR. Warden compiles it for you, and --emit-mir merely keeps a
copy.
Canonical means one thing here: the same policy always compiles to the same bytes, on any host and any platform. That is what makes MIR reviewable in a pull request, hashable into a stable identity, and portable between a macOS laptop and a Linux runner that will enforce it through completely different mechanisms.
MIR is a binary format; for this example it is 56 bytes long, and you inspect it with the tool rather than by reading the bytes yourself:
maelys-policy validate /tmp/workspace-rw.mir
maelys-policy hash /tmp/workspace-rw.mir
maelys-policy inspect /tmp/workspace-rw.mirA representative, deterministic output for this example policy:
valid MIR v2
sha256:4a72c5728ff0a1eefc87cfeeb15c7c3377ad745974676c4069e5c48c2e44fd94
MIR v2
digest: sha256:4a72c5728ff0a1eefc87cfeeb15c7c3377ad745974676c4069e5c48c2e44fd94
network: none
process-tree: required
fs: read minimal-runtime:"" [tree,error]
fs: write workspace:"" [tree,error]
fs: write temp:"" [tree,error]The raw prefix begins with 4d 4d 49 52 00 02, that is MMIR followed by
version 2. This detail is not an API: the libmaelys-mir decoder is the
contract.
The receipt it produced
A receipt is what a finished run leaves behind. It is versioned UTF-8 JSON, written after Warden has waited for the workload, and it records which decision was enforced, which mechanism enforced it, and how the command ended. Here is a real example taken from the repository fixtures, for a Bubblewrap execution with mediated networking:
{
"schema": "maelys.execution-receipt/v1",
"invocationId": "build-2026-08-24-001",
"decisionDigest": "94d14d6aece6e3a0cdf1409d9a92695b8591fe7016ae56ebad221fcd49ba8bc3",
"planDigest": "cf14f5b4eff7f425b140948c9c1d53f905c73e7ad6504aaa1d3e04490f812535",
"mediator": "maelys-netd/proxy-v1",
"backend": {"name": "bubblewrap", "version": "0.9.0"},
"capabilities": {"required": 16415, "backend": 24575},
"network": {"mode": "mediated", "frontend": "netd-proxy"},
"startedUnixMs": 1787541910254,
"forcedStopAttempted": false,
"completed": true,
"outcome": {"exitCode": 0, "termSignal": 0}
}The two digests are content-addressed identities, and they are what turns this
file from a log line into evidence. decisionDigest is the hash of the MIR, so
it names the resolved policy decision independently of the machine that enforced
it. planDigest is the hash of the plan, that is the translation of that
decision into an executable configuration for this precise backend.
Because compilation is deterministic, recompiling the same policy reproduces the
same decisionDigest. Months later you can take the policy from your
repository, recompile it, and prove that this run was allowed exactly what that
file says — no more. Two hosts can share a decisionDigest and differ on their
planDigest, which is precisely the portable-decision, local-enforcement split.
capabilities is where backend selection becomes visible. Each bit of those
integers stands for one guarantee — filesystem access rules, network refusal,
process-tree confinement, view mappings, and so on. required is the mask the
compiled plan needs; backend is the mask the selected mechanism can actually
deliver.
Selection is nothing more than that comparison. Warden keeps only the backends whose mask covers every required bit, picks the strongest of those, and fails closed when the set is empty.
A local run keeps this v1 receipt as its default contract. Richer schemas exist for layered targets: OCI executions require and produce v4, which the macOS tutorial covers.
Why no --network-frontend?
The default value is auto, but the frontend only matters for
network:mediated. This example policy asks for network:none: Bubblewrap
isolates the network, and no Egress, relay or fd 4 channel is created. The receipt
of such a run therefore carries frontend:"none" and mediator:"none", unlike
the mediated example above.
An unmodified HTTP tool through Egress
maelys-warden run --ro \
--net github.com:443 \
--network-frontend proxy \
--receipt /tmp/curl.execution.json \
--network-receipts /tmp/curl.connections.jsonl \
-- curl -I https://github.com/--net HOST:PORT adds one mediated destination. Mediated is the opposite of
direct: the workload never opens that connection itself. It hands the request to
Egress, a separate network enforcement engine that checks the destination
against the allowlist, opens the upstream connection on the workload's behalf,
counts the bytes and records the result.
The frontend is how that mediated connection is delivered to the workload.
proxy is the frontend for programs that were never written for Warden: Egress
exposes an HTTP/SOCKS proxy, and curl uses it through the ordinary proxy
environment variables, unmodified and unaware.
On Linux, auto would already choose proxy; the option is spelled out here to
make the example explicit. The relay/bootstrap is discovered automatically
inside the package. The workload stays inside a network namespace. A direct IP
connection and any other loopback port fail. Only the private relay reaches
Egress, which enforces the github.com:443 allowlist.
A Maelys-aware client on fd 4
make examples
maelys-warden run --ro \
--net example.com:80 \
--network-frontend fd4 \
-- build/release/bin/example-fd4-client example.com 80fd4 is the other frontend, for a program that speaks the Maelys protocol
itself. Instead of proxy variables, the workload receives a control channel on
file descriptor 4 and asks for a connection over it. Use it when you control the
source of the workload; use proxy for everything else.
This workload calls maelys_netclient_connect(4, ...). It does not perform an
ambient connect(): the broker translates the request to Egress's native
connector. Egress checks the destination, keeps the relayed peer and passes the
client stream back through SCM_RIGHTS. Executor neither decides nor connects.
Privileged Docker provides complementary functional coverage:
make docker-egress-bridgeA release must also pass the native amd64 and arm64 Linux runners; privileged Docker does not prove the host userns configuration.
Going further: writing your own policies
Every run above starts from a JSON policy. Here is its minimal shape, reduced to the fields that give it structure:
{
"$schema": "https://schemas.maelys.dev/mir-source/v2/schema.json",
"formatVersion": 2,
"filesystem": {
"default": "deny",
"rules": [
{"access": "write", "path": {"root": "workspace", "relative": ""}, "scope": "tree"}
]
},
"network": {"mode": "none"},
"process": {"treeConfinement": "required"}
}default:deny refuses everything that is not listed, workspace:"" names the
root supplied by the host, and scope:tree extends the rule to its descendants.
For the complete policy-authoring reference — typed permission entries, the full JSON and MIR language, compilation — see policy.maelys.dev. The policy and canonical MIR page describes the Warden side: builders, canonical bytes and digests.