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

WhatRequirementWhen
SystemLinux amd64 or arm64always
ConfinementBubblewrap, with unprivileged user namespaces allowed by the kernelalways
OCI targetsnone on a Linux hostnot available
Policy toolingmaelys-policy, from the separate maelys-sandbox-policy packageoptional

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

WhatRequirement
Compilerany C11 toolchain
maelys-system0.4.0, on the exact pinned commit, ABI 1 — required
maelys-sandbox-policy0.4.0 — optional, widens the checks
maelys-egress0.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

PLATFORMInstall 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 --version

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

PLATFORMBuild and install from source

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-cli

A read-only workspace

CODE
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

CODE
maelys-warden run \
  --policy examples/policies/workspace-rw.json \
  --emit-mir /tmp/workspace-rw.mir \
  --receipt /tmp/build.receipt.json \
  -- make test

That single command performs, in order:

  1. reads and validates workspace-rw.json;
  2. compiles it into canonical MIR v2, in memory;
  3. writes exactly those bytes to /tmp/workspace-rw.mir, atomically and with mode 0600;
  4. resolves workspace to the current directory, because --workspace is absent;
  5. compiles the MIR into a Bubblewrap plan;
  6. starts the workload with argv = {"make", "test"};
  7. waits for it to finish;
  8. 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:

CODE
maelys-policy validate /tmp/workspace-rw.mir
maelys-policy hash /tmp/workspace-rw.mir
maelys-policy inspect /tmp/workspace-rw.mir

A representative, deterministic output for this example policy:

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

CODE
{
  "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

CODE
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

CODE
make examples
maelys-warden run --ro \
  --net example.com:80 \
  --network-frontend fd4 \
  -- build/release/bin/example-fd4-client example.com 80

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

CODE
make docker-egress-bridge

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

CODE
{
  "$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.