Start here
macOS tutorial
Install Warden on Apple Silicon, run native Seatbelt policies and understand when OCI backends apply.This tutorial follows one Apple Silicon machine from its first confined run
all the way to the optional OCI targets, stopping on what each backend really
guarantees.
The quickstart gets a first confined command running in a few minutes. This tutorial goes further: you see how Seatbelt enforces the policy decision, why the proxy frontend needs an exact SBPL rule in the absence of a network namespace, and when Apple Container or Krun become a relevant target.
Prerequisites
To run Warden
| What | Requirement | When |
|---|---|---|
| System | Apple Silicon, macOS 15 or newer | always |
| Confinement | Seatbelt, already part of macOS | always |
| OCI targets | libkrun 1.19.4 with libkrunfw 5.5.0 on macOS 15 or newer; Apple Container 1.3.0 or Apple Containerization 0.41.0 on macOS 26 | optional |
| 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.
Which OCI targets you can reach depends on which of those components you installed, and on your macOS version — the Apple ones need macOS 26, libkrun only needs macOS 15.
No Intel Mac support is announced.
To build from source
| What | Requirement |
|---|---|
| Compiler | the Xcode Command Line Tools |
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.
Seatbelt is the program-confinement mechanism built into macOS. Warden compiles your policy into an SBPL profile and starts your command under it, so the kernel itself refuses a forbidden read or connection. Warden drives it, and never asks it for authority your policy did not request.
Install Warden
Install a released binary
macOS on Apple Silicon
Every method installs the same binaries.
The tap publishes bottles, so this downloads a prebuilt binary.
brew install maelys-dev/tap/maelys-warden
maelys-warden --versionAlways verify a downloaded artifact before installing it: see release integrity.
The Apple Container, Containerization and Krun components are packaged separately, installed further down this tutorial and only if you target OCI images.
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.
macOS on Apple Silicon
Every method installs the same binaries.
Homebrew compiles the formula instead of installing the bottle.
brew install --build-from-source maelys-dev/tap/maelys-warden
maelys-warden --versionConfine a command with inline flags
maelys-warden run --ro --workspace "$PWD" \
-- /bin/sh -c 'cat README.md'
maelys-warden run --rw --workspace "$PWD" \
-- /bin/sh -c 'echo ok > build.out'Nothing in those two lines names a sandbox, yet both runs are confined. Warden
compiled the flags into a decision, then selected a backend: the concrete
enforcement mechanism supplied by the operating system, chosen per platform. On
this host that backend is seatbelt.
Seatbelt is what actually refuses a forbidden read. The CLI relays stdio through Executor pipes, so Seatbelt is never granted general permission over your terminal; it enforces the filesystem rules, the network refusal and the propagation of the policy to every descendant.
How Warden picks a backend
You never name a backend. Warden collects the candidates that can prove they enforce your decision, and selects the strongest of them — never the most convenient one.
For a local command it knows posix and seatbelt on this platform. For an OCI
target on Apple Silicon it can additionally reach krun, krun-bubblewrap and
apple-containerization.
Two further backends ship and probe correctly, apple-container and
apple-container-bubblewrap, but the CLI cannot select them. Since 0.38 every
OCI run carries an exact VM and cgroup budget, and the Apple Container service
cannot prove those limits. Rather than approximate them, Warden declares those
candidates ineligible. They remain reachable through the historical driver API
only.
To make the choice visible in a test or a diagnostic, name one explicitly:
maelys-warden run --ro --backend seatbelt -- /usr/bin/trueAn explicit name is a demand, not a hint. If that backend cannot enforce the decision, the run is refused — Seatbelt cannot reproduce the view mappings and tmpfs that Bubblewrap provides, and a plan requiring them is never quietly launched under something weaker.
The receipt a run leaves behind
Every run can leave a file behind that says what was enforced. Ask for one with
--receipt, and Warden writes it after it has waited for the workload:
maelys-warden run --ro --workspace "$PWD" \
--receipt /tmp/execution.json \
-- /usr/bin/true
python3 -m json.tool /tmp/execution.json{
"schema": "maelys.execution-receipt/v1",
"invocationId": "tutorial-macos-001",
"decisionDigest": "94d14d6aece6e3a0cdf1409d9a92695b8591fe7016ae56ebad221fcd49ba8bc3",
"planDigest": "cf14f5b4eff7f425b140948c9c1d53f905c73e7ad6504aaa1d3e04490f812535",
"mediator": "none",
"backend": {
"name": "seatbelt",
"version": "seatbelt-sbpl-v2"
},
"capabilities": {
"required": 31,
"backend": 24863
},
"network": {
"mode": "none",
"frontend": "none"
},
"startedUnixMs": 1787541910254,
"forcedStopAttempted": false,
"completed": true,
"outcome": {
"exitCode": 0,
"termSignal": 0
}
}A local target writes the v1 contract by default. Four fields carry most of the meaning.
backend names the mechanism that did the enforcing — here seatbelt, with
the version of the profile generator that produced the SBPL.
decisionDigest is the hash of the compiled decision. It identifies what the
run was allowed to do, independently of the machine that enforced it.
planDigest is the hash of the plan: that same decision translated into
an executable configuration for this precise backend. Two hosts can share a
decisionDigest and differ on their planDigest — that is the
portable-decision, local-enforcement split.
capabilities is where backend selection becomes visible. Each bit of those
integers stands for one guarantee. required is what the plan needs, backend
is what the selected mechanism can deliver. Selection is nothing more than
keeping the backends whose mask covers every required bit.
Because compilation is deterministic, recompiling the same policy months later
reproduces the same decisionDigest. That is what turns this file from a log
line into evidence.
The Linux tutorial walks the same contract field by field, and the receipts reference covers the richer v3 and v4 contracts used by OCI targets.
Network: what is delivered
network:none: delivered and enforced by the SBPL profile;network:mediated+fd4: delivered for a Maelys-aware workload;network:mediated+proxy: delivered for standard HTTP/SOCKS tools.
Mediated is the opposite of direct: the workload never opens the 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.
In a receipt, two fields describe that pairing. The mediator names the
component that made the network decision — Egress — and the frontend says
through which channel the decision was delivered to the workload. proxy is for
an unmodified tool, which simply honors proxy environment variables; fd4 is
for a program that speaks the Maelys protocol itself on file descriptor 4.
The auto value now resolves to proxy on macOS. It has no effect for a none
policy; for a mediated policy, libmaelys-warden starts one per-execution
Egress and injects its proxy URLs.
A complete fd 4 example:
make examples
maelys-warden run --ro \
--net example.com:80 \
--network-frontend fd4 \
-- build/release/bin/example-fd4-client example.com 80One line per connection
The execution receipt describes the run. A run can open many connections, and
those are recorded separately: add --network-receipts and Egress appends one
JSON object per line as each connection finishes.
maelys-warden run --ro --workspace "$PWD" \
--net example.com:443 \
--invocation-id tutorial-macos-002 \
--receipt /tmp/execution.json \
--network-receipts /tmp/connections.jsonl \
-- curl https://example.com/You now hold two files describing one run, and --invocation-id is what ties
them together: Warden writes that value into the execution receipt, Egress writes
it into every connection line. Without it, a tool reading both files would have
to guess the pairing from filenames or timestamps. It is a correlation label
only — not a secret, and not a grant of any kind.
How the Seatbelt proxy frontend is confined
Under Bubblewrap, the proxy relies on a loopback relay placed inside a private network namespace; the workload cannot reach any other host port. macOS has no such namespace. Delivering the same contract therefore requires an exact SBPL rule that permits only the execution's own Egress loopback port. The adapter:
- starts Egress on
127.0.0.1:0and waits for the effective port; - reserves the same port on
::1without a listener, because the SBPLlocalhosttoken covers both families; - injects the proxy URLs;
- passes only
127.0.0.1:PORTto the Seatbelt backend; - stops the workload before Egress and releases the IPv6 reservation.
The SBPL profile permits network-outbound only towards localhost:PORT. A
direct destination, a neighbouring loopback port and the inert IPv6 path are all
covered by the macOS behavioral test. No relay and no fallback to the ambient
network is used.
Writing a policy: JSON and MIR
Inline flags stop scaling once a policy has more than a handful of rules. A policy file holds the same decision in reviewable form:
maelys-warden run \
--policy examples/policies/workspace-readonly.json \
--emit-mir /tmp/reviewed.mir \
--receipt /tmp/execution.json \
-- /usr/bin/trueThe JSON source is compiled into MIR, in memory, before anything is spawned.
MIR is the policy decision in canonical form — what Warden actually decided once
the source was read, validated and normalized — and not the input language
itself. You never hand-author it; --emit-mir simply keeps those canonical bytes
for inspection.
Canonical means 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 the decisionDigest you saw above, and portable between a macOS laptop and
a Linux runner that will enforce it through a completely different mechanism.
maelys-policy reads those bytes back. It ships with the policy product, not
with Warden, so install it first — see
policy.maelys.dev:
maelys-policy inspect /tmp/reviewed.mir
maelys-policy hash /tmp/reviewed.mirinspect prints the normalized decision in readable form — the permission
entries as Warden resolved them, not as you wrote them. hash prints the same
digest the receipt records, which is how a script checks that a policy still
compiles to the decision you reviewed:
test "$(maelys-policy hash /tmp/reviewed.mir)" = "$(python3 -c \
'import json,sys;print(json.load(open("/tmp/execution.json"))["decisionDigest"])')"The policy language is a product of its own — typed permission entries, the C builder API, offline compilation. The full authoring reference lives at policy.maelys.dev, and Policy introduces the concepts as Warden uses them.
Apple Container: another target, not an implicit replacement
On macOS 26 Apple Silicon, Warden can also run a native linux/arm64 OCI image,
or a translated linux/amd64 one, inside Apple Container 1.3.0. This path is
optional and uses a separate Swift driver.
Everything above confined a command that already existed on your Mac. An OCI target is a different shape of run: the program comes from an image, so the enforcement stack gains a virtual machine layer underneath the policy decision.
--image REFERENCE@sha256:DIGEST is the switch between the two. Without it the
CLI builds a local target, and only posix, seatbelt and bubblewrap are
candidates. With it the run becomes an OCI target, and that is what makes
the four optional candidates selectable at all. A local command is never
converted into an image for you.
Install the component only if you use OCI targets:
brew install maelys-dev/tap/maelys-warden-apple-container-driverApple Container 1.3.0 must be installed and configured separately. The driver
artifact does not require Xcode at runtime. The CLI builds an explicit OCI
TrustedRunSpec:
maelys-warden run \
--image alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc \
--platform linux/arm64 \
--receipt-schema v4 \
--receipt /tmp/apple-native.json \
-- /bin/echo helloTo request Rosetta explicitly, use an image that has an amd64 variant together
with --platform linux/amd64. Stable Rosetta proof is mandatory; there is no
fallback to arm64.
Passing a value into the image
An OCI run starts from an empty environment: --environment inherit is
refused for an OCI target, precisely so that your shell cannot leak into a
sealed image. Anything the workload needs, you therefore name explicitly with
--env, which is repeatable:
maelys-warden run \
--image alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc \
--platform linux/arm64 \
--env CI \
--env BUILD_PROFILE=release \
--receipt-schema v4 \
--receipt /tmp/apple-native.json \
-- /bin/sh -c 'echo "$BUILD_PROFILE in CI=$CI"'The two spellings are not interchangeable. --env CI imports the value of CI
from the environment maelys-warden itself was started in, and writes only the
name on the command line. --env BUILD_PROFILE=release writes the value there
as well — fine for a build profile, wrong for a credential, since any local
process can read a command line with ps. The
command and process model
lays that contrast out in full.
The receipt an OCI run produces
An OCI target stacks several mechanisms, so its receipt uses the v4 contract:
--receipt-schema v4 is required for an OCI target and produces v4, while a
local target keeps v1. Receipt v3 remains byte-for-byte frozen for older
consumers, but it is no longer what a current OCI run writes.
Four groups replace the flat v1 shape. digests gathers the identities of the
MIR, plan, RunSpec, artifact, target and evidence — digests.mir is the v1
decisionDigest, and digests.plan its planDigest. target records what was
executed and on which platform. selection shows the chosen candidate, the
required and effective capability masks, and the candidates that were rejected.
layers enumerates the enforcement chain from outermost to innermost.
Since 0.26.0, v4 additionally freezes the selected Krun runtime variant, its feature set, its admitted component versions and its evidence-bound closure, so the receipt names not only which mechanism ran but which exact build of it. The Krun section below shows a complete v4 receipt.
The simple candidate provides the VM, the read-only OCI root, VirtioFS/tmpfs and the UDS/vsock Egress proxy. The composite injects the verified arm64 Warden bundle and adds the recursive READ/WRITE rules; the image does not have to supply Bubblewrap. It does not advertise DENY, PROTECT_CREATE, inner seccomp or guest fd4 networking. The capability matrix compares those guarantees to host Seatbelt and Bubblewrap.
Krun: a libkrun microVM for linux/arm64
On Apple Silicon, the same --image option also reaches the krun and
krun-bubblewrap candidates, restricted to digest-pinned linux/arm64 targets.
They come from a separate optional package:
brew tap libkrun/krun
brew trust libkrun/krun
brew install --build-from-source libkrun/krun/libkrunfw
brew install maelys-dev/tap/maelys-warden-libkrun
brew install maelys-dev/tap/maelys-warden-krun-drivermaelys-warden-libkrun is the Warden-owned headless libkrun runtime; the driver
package installs the signed host driver, the deterministic OCI→ext4 materializer
and the arm64 guest bundle. It also brings maelys-warden image inspect and
maelys-warden image import, the only entry path for an image into the local
store. See the Krun driver package for the full install contract.
maelys-warden run --rw --workspace "$PWD" \
--image ghcr.io/maelys/tool@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
--platform linux/arm64 \
--backend krun-bubblewrap \
--receipt-schema v4 \
--receipt /tmp/krun.json \
-- /usr/bin/tool buildThe simple candidate boots the sealed ext4 root read-only with the requested VirtioFS and tmpfs views; the composite adds the guest Warden Bubblewrap and its recursive READ/WRITE rules. There is no amd64 variant: the hypervisor requires the guest ISA to be the host's. For a mediated policy, the guest relay reaches the execution's Egress over AF_VSOCK port 4520, with no network card and no TSI inside the VM. The VMM itself is confined by a deny-default host Seatbelt profile, probed before the candidate is registered.
The v4 receipt of a Krun composite run
Here is a real example, taken verbatim from the repository fixtures, for the
krun-bubblewrap candidate on the headless runtime:
{
"schema": "maelys.execution-receipt/v4",
"executor": {
"abi": 8,
"capabilityRegistry": "maelys.executor-capabilities/8"
},
"invocationId": "krun-bubblewrap-native",
"digests": {
"mir": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"plan": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
"runSpec": "sha256:3333333333333333333333333333333333333333333333333333333333333333",
"artifact": "sha256:4444444444444444444444444444444444444444444444444444444444444444",
"target": "sha256:5555555555555555555555555555555555555555555555555555555555555555",
"evidence": "sha256:6666666666666666666666666666666666666666666666666666666666666666",
"targetEvidence": "sha256:7777777777777777777777777777777777777777777777777777777777777777"
},
"target": {
"kind": "oci",
"reference": "ghcr.io/maelys/tool",
"imageDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"platform": "linux/arm64",
"workingDirectory": "/workspace"
},
"selection": {
"candidate": "krun-bubblewrap",
"capabilities": {
"required": {
"mask": "0x000000000000081f",
"names": [
"execution",
"filesystem",
"network-none",
"process-confinement",
"fs-access-rules",
"process-tree-lifecycle"
]
},
"effective": {
"mask": "0x00000000000008df",
"names": [
"execution",
"filesystem",
"network-none",
"process-confinement",
"fs-access-rules",
"fs-view-mapping",
"fs-tmpfs",
"process-tree-lifecycle"
]
}
},
"candidates": [
{
"name": "krun-bubblewrap",
"state": "eligible",
"reason": "eligible",
"diagnostic": "sealed MWOCI/3 root and trusted Warden guest bundle"
}
]
},
"layers": [
{
"name": "seatbelt-host",
"version": "maelys.warden.krun-host-seatbelt/v1",
"platform": {
"mode": "native",
"execution": "darwin/arm64"
},
"capabilityContribution": {
"mask": "0x0000000000000000",
"names": []
}
},
{
"name": "krun",
"version": "krun-bubblewrap-warden-headless-1.19.4-fw-5.5.0-seatbelt1",
"platform": {
"mode": "native",
"execution": "linux/arm64"
},
"capabilityContribution": {
"mask": "0x00000000000008cf",
"names": [
"execution",
"filesystem",
"network-none",
"process-confinement",
"fs-view-mapping",
"fs-tmpfs",
"process-tree-lifecycle"
]
},
"configuration": {
"vcpus": 2,
"memoryMiB": 1024,
"defaults": "maelys.warden.krun/v1",
"runtime": {
"variant": "warden-headless",
"features": ["blk"],
"components": [
{"name": "libkrun", "version": "1.19.4"},
{"name": "libkrunfw", "version": "5.5.0"}
],
"closureEvidence": "sha256:6666666666666666666666666666666666666666666666666666666666666666"
},
"helperEvidence": "sha256:6666666666666666666666666666666666666666666666666666666666666666"
}
},
{
"name": "bubblewrap",
"version": "warden-static-linux-arm64/evidence-sha256:6666666666666666666666666666666666666666666666666666666666666666",
"platform": {
"mode": "native",
"execution": "linux/arm64"
},
"capabilityContribution": {
"mask": "0x0000000000000010",
"names": [
"fs-access-rules"
]
}
}
],
"network": {
"mode": "none",
"frontend": "none",
"mediator": "none"
},
"startedUnixMs": 1787712000000,
"lifecycle": {
"forcedStopAttempted": false,
"domainCompleted": true,
"outcomeKnown": true
},
"outcome": {
"exitCode": 0,
"termSignal": 0
}
}Read layers from outside in: the host Seatbelt profile that confines the VMM,
then the microVM, then the guest Bubblewrap that lowers the recursive filesystem
rules. Only the last two contribute capabilities; seatbelt-host contributes
none, because it protects the host rather than the workload. Under
layers[1].configuration.runtime, v4 pins the exact runtime that ran:
warden-headless, the single blk feature, and the admitted libkrun and
libkrunfw versions.
The policy asked for network:none, so no Egress was started: mediator and
frontend are both none.
Going further: writing your own policies
Every run above starts from a policy, inline through --ro/--rw or explicit
through --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": "read", "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.