Security and evidence

Execution receipts

Read execution receipt schemas, capability evidence, lifecycle outcomes and per-connection Egress JSONL records.

Why Warden emits receipts

The policy says what must be enforced. The receipt records what Warden selected, which evidence was sealed and how the execution ended. It is operational and audit evidence, not a log of workload output and never a secret store.

CODE
policy source ──► canonical MIR ──► candidate planner ──► sealed artifact ──► spawn
                      │                    │                    │              │
                      └──────── digests ───┴── selection ──────┴── outcome ──┘
                                                │
                                                ▼
                                        execution receipt

The vocabulary of a receipt

Four groups of fields carry most of the meaning. They are worth reading before the JSON examples below.

planDigest and decisionDigest are the content-addressed identity of the resolved decision. decisionDigest names the canonical MIR that was compiled; planDigest names the sealed plan that was actually handed to a backend. They are proof of exactly what was enforced, and they are not secrets: two runs that enforced the same decision produce the same digest, which is what makes a receipt comparable across hosts and over time. In v3 and v4 these move into a digests object that also names the RunSpec, artifact, target and evidence. See Policy and canonical MIR for where the decision comes from.

backend is the concrete operating-system mechanism that enforced the plan. It is one of seven values: posix, seatbelt, bubblewrap, apple-container, apple-container-bubblewrap, krun and krun-bubblewrap. The hyphenated pairs are composite backends: an outer isolation layer wrapping an inner confinement layer. In v3 and v4 the same information appears as selection.candidate plus a layers array listing the chain outer-to-inner.

mediator and network.frontend describe how a mediated network decision was delivered, not whether it was allowed. frontend is the shape the workload sees — fd4 for a Maelys-aware program that receives connections over file descriptor 4, or egress-proxy for a standard program pointed at an HTTP/SOCKS proxy. mediator is the matching current Egress protocol identity, maelys-egress/fd4-v1 or maelys-egress/proxy-v1. Both are none when nothing was mediated. See Network mediation and Egress.

capabilities is a comparison, not a single value. required is what the sealed plan demanded; backend (v1) or effective (v3 and v4) is what the selected backend could actually guarantee. The required set must be a subset of the available set — that intersection is backend selection, and a backend that cannot cover a requirement is never chosen as a weaker fallback.

Which schema to request

SchemaIntended use
v1Default local CLI compatibility receipt
v2Frozen ABI 7 format; never selected implicitly
v3Structured RunSpec evidence for OCI, translated and composite backends; byte-for-byte frozen
v4v3 plus an evidence-bound Krun runtime closure; the only schema an OCI target accepts

An OCI target requires --receipt-schema v4 and produces v4. A local target keeps v1 unless it opts in with --receipt-schema v3 or v4.

v4 arrived in Warden 0.26.0 as a new schema rather than as a change to v3, which stays byte-for-byte frozen for the consumers already reading it. Warden never silently rewrites an older schema into a new meaning.

What v4 adds to v3

v4 is v3 with one substitution, and it applies only to the krun layer: the fixed krun.runtime version block of v3 becomes an explicit declaration of the dynamic runtime closure that was actually admitted when the backend was built.

  • variant — warden-headless or upstream-gpu;
  • features — the libkrun feature set, drawn from blk, net and gpu;
  • components — the admitted component versions: libkrun and libkrunfw, joined by libepoxy, virglrenderer and MoltenVK when the GPU closure is the one in use;
  • closureEvidence — the digest binding that declaration to the files pinned during backend construction.

That is what makes the difference between the headless and the GPU runtime auditable after the fact rather than assumed. Every other group — digests, target, selection, layers, network, lifecycle and outcome — carries its v3 meaning unchanged, so a reader of v3 needs no new vocabulary beyond that block, and a receipt from any other backend is a v3 receipt with a v4 label.

Receipt v4 example

EXECUTION RECEIPTApple Container + Bubblewrap
{
  "schema": "maelys.execution-receipt/v4",
  "executor": {
    "abi": 8,
    "capabilityRegistry": "maelys.executor-capabilities/8"
  },
  "invocationId": "native-composite",
  "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": "apple-container-bubblewrap",
    "capabilities": {
      "required": {
        "mask": "0x000000000000001f",
        "names": [
          "execution",
          "filesystem",
          "network-none",
          "process-confinement",
          "fs-access-rules"
        ]
      },
      "effective": {
        "mask": "0x00000000000000df",
        "names": [
          "execution",
          "filesystem",
          "network-none",
          "process-confinement",
          "fs-access-rules",
          "fs-view-mapping",
          "fs-tmpfs"
        ]
      }
    }
  },
  "startedUnixMs": 1787541910254,
  "layers": [
    {
      "name": "apple-container",
      "version": "1.3.0",
      "platform": {
        "mode": "native",
        "execution": "linux/arm64"
      }
    },
    {
      "name": "bubblewrap",
      "version": "warden-static-linux-arm64",
      "platform": {
        "mode": "native",
        "execution": "linux/arm64"
      }
    }
  ],
  "network": {
    "mode": "none",
    "frontend": "none",
    "mediator": "none"
  },
  "lifecycle": {
    "forcedStopAttempted": false,
    "domainCompleted": true,
    "outcomeKnown": true
  },
  "outcome": {
    "exitCode": 0,
    "termSignal": 0
  }
}

This is what an OCI target produces. The digests and capability values above depend on the exact host and backend version that produced them — never copy them into your own policy or tooling. The normative schemas are execution-receipt-v4.schema.json and, for the frozen predecessor, execution-receipt-v3.schema.json.

The important groups are:

  • digests: identities of the MIR, plan, RunSpec, artifact, target and evidence;
  • target: what was executed and on which platform;
  • selection: selected candidate, required/effective capabilities and rejected alternatives;
  • layers: the outer-to-inner enforcement chain for a composite backend;
  • network: decision, frontend and mediator;
  • lifecycle and outcome: whether cleanup completed and how the workload ended.

For v3 and v4, capability names are derived from mask under the declared Executor ABI. A mismatch invalidates the receipt. Translation layers can contribute zero capabilities while still recording their stable translation identity.

Receipt v1 example

A local run that does not ask for a richer schema gets v1. It is flatter: one backend instead of a layer chain, integer capability masks instead of named sets, and top-level digests. This one recorded a mediated run behind the Egress proxy frontend:

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

capabilities.required (16415) is a subset of capabilities.backend (24575), which is why bubblewrap was eligible to run this plan.

The digests and capability values above depend on the exact host and backend version that produced them — never copy them into your own policy or tooling. The normative schema is docs/schemas/execution-receipt-v1.schema.json.

Write receipts without mixing streams

CODE
maelys-warden run --ro \
  --invocation-id build-42 \
  --receipt-schema v4 \
  --receipt execution.json \
  --network-receipts connections.jsonl \
  -- make test

The execution receipt is written after wait completes because its terminal outcome is not known earlier. Both output files are created as private files, and neither is ever mixed into the workload's stdout.

Network receipts are separate

--receipt answers "what was enforced, and how did the run end". It does not record individual connections. That is the job of --network-receipts FILE, which Egress writes as JSON Lines: one secret-free JSON object per authorized connection, carrying the policy identity, the destination, byte counts and the closure result.

CODE
run_id="release-2026-08-26"

maelys-warden run --ro \
  --net github.com:443 \
  --invocation-id "$run_id" \
  --receipt execution.json \
  --network-receipts connections.jsonl \
  -- curl https://github.com/

Correlating the two files

--invocation-id is the join key. The value you pass is copied verbatim into the invocationId field of the execution receipt and of every network receipt line produced by the same run. Nothing else links the two files, so a run that omits --invocation-id produces evidence you cannot reassemble later. With it, auditing a run is a single filter over both artifacts.

What a line looks like

Network receipt fields are not published as a normative schema the way execution receipts are, so treat the shape below as illustrative rather than as a contract — the categories are stable, the exact spelling is not:

CODE
{"invocationId":"release-2026-08-26","policyDigest":"cf14f5b4eff7f425b140948c9c1d53f905c73e7ad6504aaa1d3e04490f812535","host":"github.com","port":443,"result":"closed","bytesFromClient":1024,"bytesToClient":52480}

Each line is one complete JSON object on one line, which means the file can be appended to during the run and filtered afterwards without a parser that understands the whole document.

Cardinality is deliberately different

A run always produces at most one execution receipt. It produces zero or more network receipt lines, and the count is a property of the policy, not of a failure:

CODE
one Warden execution receipt
          │
          ├── zero network receipts       (network none)
          └── many network receipts       (one per Egress connection)

When network.mode is none, no connection ever reaches Egress, so connections.jsonl is created and stays empty. When the mode is mediated, there is exactly one line per authorized connection — a workload opening four connections writes four lines. --net direct is the third case: the traffic bypasses Egress entirely, so the file yields no useful observation there either.

Receipts never contain proxy credentials, private AF_UNIX paths or secret environment values. See Network mediation and Egress and the security model for the trust boundaries.