Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

erebus

The proof layer for AI execution.

erebus is the launcher that emits a signed, externally anchored receipt of every execution — verifiable offline with erebus or stock cosign.

The receipt proves this version ran, on this machine, at this time, and the trail was not rewritten. verify-attest --response-body checks those bytes and re-derived tool calls; --expected-output-hash pins a deterministic tool’s result digest. It does not prove the model computed them or that the answer is right.

Each receipt (.erebus.attest, spec 2.0: DSSE envelope over an in-toto statement) states which exact code and weights ran, on which enrolled machine, when, and with which input and output digests. It is signed by the operator, co-signed by the machine, hash-chained to the previous receipt, and logged in a Merkle transparency log whose checkpoints are timestamped by an independent RFC 3161 authority.

erebus keygen --key-dir keys                            # operator key
erebus enroll --registry $ERE_REGISTRY --token-file token   # once per host
erebus build ./my-agent -o agent.erebus && erebus sign agent.erebus --key keys/<fp>.key

ERE_ATTEST_KEY=keys/<fp>.key ./agent.erebus --task nightly  # run → signed receipt
erebus serve agent.erebus --attest-key keys/<fp>.key --push # or: one receipt per inference call
erebus agent run --provider openai-compat --base-url http://127.0.0.1:11434 \
  --model llama3.2 --task "…" --attest-key keys/<fp>.key \
  --mcp-stdio '["python3","mcp_server.py"]' --allow-mcp --stream --encapsulate
# Streaming display is unsigned; receipt = assembled final body digests.
# Execution proven; task success = tests / human approve / golden checks — not the receipt.

erebus verify-attest agent.erebus.attest --bundle agent.erebus --strict --proof --log-key $REG
erebus evidence export agent.erebus --log-key $REG -o evidence.zip   # hand it to the auditor
erebus verify-evidence evidence.zip --bundle agent.erebus --operator-key <fp> --log-key $REG

Where to go next:

What happens, and who does it

StepWhat happensWho
build / signapp, runtime, libraries and weights in one sandboxed binary; Ed25519 signature over SHA-256(payload ‖ metadata ‖ footer), anti-downgradedeveloper
run / serve --attest-keythe launcher verifies the bundle, then signs a receipt: artifact hashes, rootfs_root, arguments, stdout/stderr digests and exit_code (or request/response body digests per inference call), previous receipt hash; co-signed by the enrolled machine keyautomatic, on every run
--pushreceipt published to the registry’s transparency log; inclusion proof, signed checkpoint and RFC 3161 token stored beside itautomatic, or attest --push
verify-attestone receipt or a whole trail: --chain, --proof, --strict — offlineanyone
evidence export / verify-evidenceone zip with receipts, proofs, TSA tokens and keys; verified offline against pinned keysoperator → auditor

How it runs

The receipt is only worth something if it is produced where the code runs, by something the operator cannot silently bypass. That is why erebus is also a launcher:

  • Single binary — chmod +x && ./agent.erebus; no daemon, no root. Runtimes: Python and native binaries.
  • Integrity at every launch — SHA-256 over payload ‖ metadata, Ed25519 against trusted keys, fail-cold; warm starts re-check the rootfs Merkle root.
  • Sandbox — user namespaces, seccomp and Landlock on Linux.
  • Model serving — erebus serve exposes an OpenAI-compatible API in front of the bundled engine (GGUF via llama.cpp) and writes one receipt per call, streaming relayed unchanged.
  • HTTP agent — erebus agent run talks OpenAI-compatible or Anthropic HTTP (--base-url for Ollama, Azure, llama-server, …), MCP tools in the loop (--mcp / --mcp-stdio), optional --stream (display unsigned; receipt hashes the assembled body), and --encapsulate so the receipt subject is a task .erebus hash. The receipt proves execution; task success stays a separate layer.
  • Chaos-tested — the full proof chain and adversarial harnesses (SIGKILL, tampering, registry faults, cold-start storms) run in CI on every commit.

Advanced and optional: universal multi-platform binaries, SISR delta updates, Agentfile.yaml declarative builds. They are documented in the Architecture and Guides sections and are not needed for the proof chain.

Status

The P0 proof chain is replayed in CI on every commit (proof.yml). Evidence export exists. T2 is not done. There are no production customers.

The receipt proves this version ran, on this machine, at this time, and the trail was not rewritten. verify-attest --response-body checks those bytes and re-derived tool calls; --expected-output-hash pins a deterministic tool’s result digest. It does not prove the model computed them or that the answer is right.

Get started

git clone https://github.com/Encapsul/erebus.git && cd erebus
cargo build --release
./target/release/erebus doctor

Then follow the Quickstart.

The Problem

“Prove what ran”

A company runs an AI agent on 500 field machines. The agent is a GGUF model, a Python server, a few tools and a configuration. An auditor asks:

“Show me that the agent that ran on machine #247 on 15 September was exactly the version you validated in July, what it was given and what it answered — and that this record was not edited since.”

Today the honest answer is a set of application logs and a tracing dashboard. Both are controlled by the operator being audited: they can be edited, filtered or regenerated, and neither ties the log line to the exact bytes that executed or to a specific machine.

Why the usual tools do not answer it

Each of these is good at what it does; none produces the evidence above on its own.

ToolWhat it givesWhat it does not give
Application logstext lines written by the appa signature, a binding to the executed bytes, protection from the operator
LLM tracing / observability (hosted or self-hosted)rich traces in the vendor’s or operator’s databasetamper evidence against the account owner; offline verification by a third party
Build provenance (Sigstore, SLSA, in-toto)who built which artifact from which sourceanything about runs: when, where, with what input
Container signing (OCI + cosign)the image was signed before deploymentthat this image is what actually ran on a given host at a given time
Tamper-evident log storesappend-only, verifiable storagewhat is appended: it records what the application tells it
TEE remote attestationthe platform and code measured by hardwareper-run records; it needs specific hardware and cloud support

erebus reuses the first four’s standards (DSSE, in-toto, transparency logs, cosign) and applies them to executions, produced by the launcher itself. See Comparison for the full picture.

Why it matters now

  • EU AI Act — high-risk systems must automatically record events over their lifetime (Art. 12), and providers and deployers must keep those logs (Art. 19, Art. 26); obligations phase in over 2026–2027. erebus gives the compliance team evidence for these logs; it is not a certification.
  • SOC 2 / ISO 27001 — auditors ask for evidence of software integrity and change control on deployed systems.
  • Public procurement and defense — air-gapped deployments must be verifiable without calling a vendor.
  • Vehicles and industry — UNECE R156 requires traceability of software updates on embedded systems.

The erebus answer

The auditor receives one file and verifies it without an account or network:

erebus evidence export agent.erebus --log-key $REG -o evidence.zip   # operator
erebus verify-evidence evidence.zip --bundle agent.erebus \
  --operator-key <fp> --log-key $REG                                 # auditor

What that establishes, and its limits, are on What erebus proves; the auditor’s walkthrough, including checking the timestamps with openssl and the signatures with cosign, is Auditing an erebus trail.

What erebus proves — and what it does not

This page is the honest boundary of the product. Everything in the README, the pitch and the sales deck must stay inside it.

The five facts a receipt establishes

A spec 2.0 receipt (format) lets a verifier who trusts nobody — not the operator, not the machine, not erebus — establish:

#FactEvidence in the signed statement
1What ran — exactly this code and these weights, unmodified at launchartifact.bundle_hash, artifact.payload_hash, execution.rootfs_root (Merkle root of the extracted rootfs, re-checked on every warm start)
2Where — on a host whose key was enrolled and certified for a named ownermachine co-signature in the DSSE envelope + registry-signed certificate (erebus enroll, verify-attest --strict)
3When — at a time nobody can backdateleaf in a public Merkle transparency log, signed checkpoint, RFC 3161 timestamp from an independent TSA (verify-attest --proof)
4With what, producing what — for a served model, one receipt per inference call with the SHA-256 of the request and response bodies; for a batch run, the launch arguments, piped stdin, stdout/stderr digests and exit code (capture on Linux isolation ≥ 2 and macOS)execution.input_hash, execution.stdin_hash, execution.output_hash, execution.exit_code, execution.request
5That the trail is intact — no receipt was deleted, reordered or edited afterwardsexecution.prev_attestation_hash chain (verify-attest --chain) contradicted by the public log if anything changes

In one sentence: a flight recorder for AI agents — non-repudiation of what was executed, and tamper evidence for the record of it.

What a receipt does not establish

ClaimWhy notWhat covers it instead
The computation was faithful: output really is f(input, weights)Proving this needs hardware enclaves (TEE attestation) or zkML, 100–1000× the cost of the inference; nobody runs it on production LLMsRoadmap option: run inside a confidential VM and embed the TEE quote in the receipt. Not planned before a customer asks.
The agent did the right thing — correct, safe, within policyThat is a judgement about semantics, made before deployment by evaluations and guardrails. erebus records what happened; it does not grade it.Your eval suite; the receipt then proves the graded version is the one that ran.
The plaintext of inputs and outputsReceipts carry digests, not content. The operator keeps the data; the digest proves that what they hand the auditor is what was hashed at run time.Retention is the operator’s job; erebus audit bundles receipts + evidence.
A host with root compromisedThe machine key is a software key in ~/.local/share/erebus/machine/machine.key (0600). Root can copy it.TPM2-bound key and quote (roadmap, optional per fleet).
The RFC 3161 token is cryptographically validerebus checks the token structurally (imprint, policy, time) and stores itopenssl ts -verify against the TSA chain, documented in the spec
Compliance with a given regulationMapping receipts to EU AI Act record-keeping, SOC 2 or ISO 42001 is the compliance team’s workerebus gives that team evidence instead of screenshots

Why this boundary is the right product, not a shortfall

Every piece of trust infrastructure that actually changed an industry stops exactly here:

  • Certificate Transparency does not prove a certificate is legitimate; it makes every issuance visible and impossible to erase. That was enough.
  • Sigstore / SLSA prove who built what from which source, not that the code is bug-free.
  • A flight recorder does not prevent the crash; it makes the investigation possible and the account undeniable.
  • An accounting audit trail does not prove decisions were wise; it proves the books were not cooked afterwards.

And the buyer asks for exactly this. High-risk obligations under the EU AI Act are about record-keeping and traceability (logging, Art. 12; obligations of providers and deployers, Art. 19 and Art. 26), not about a mathematical proof of correctness. SOC 2 and ISO 42001 are the same shape: show that the record exists, is complete, and could not have been altered. Selling level-2 proof would be selling something nobody requires and nobody can afford.

The three questions every buyer (and every investor) asks

  1. Who needs this now? Teams that deploy agents on machines they do not fully control and must answer to someone who does not trust them: defense and public administration (air-gapped by regulation), industrial edge, AI vendors selling into regulated buyers. The answer must be a name, not a sector — see the traction plan in ROADMAP.md §7.3.
  2. Why now? Agents are starting to act without a human in the loop, and the record-keeping obligations for high-risk AI systems are becoming enforceable. Logs the operator can edit were tolerated for chatbots; they are not tolerated for systems that act.
  3. Why isn’t this a feature of an observability or agent platform? Because a tool operated by the operator cannot, by construction, produce evidence the operator does not control. Datadog, LangSmith or an in-house trace store answer “what happened?” to the operator. A receipt anchored in a neutral, externally timestamped log answers it to the auditor. The neutrality of the log is the product; the launcher is how we get the signature at the only point the operator cannot bypass.

The one-sentence answer to “and if the agent lies?”

We do not judge the agent. We make it impossible to lie about what it did — the way Certificate Transparency made it impossible to issue a certificate in secret.

Execution attestation — spec 2.0 / 2.1

This page is the normative description of the receipts produced by erebus attest, the launcher stub and the attesting proxy (erebus serve --attest-key). It is also the document the predicate URI https://erebus.dev/attestation/execution/v2 points at. Spec 2.1 is 2.0 plus the signed tool calls of a served call (§3.2) and the tool context of its request, linked across the trail (§3.3); stop receipts (§3.5) and human approvals (§3.6); it keeps the v2 predicate URI.

A receipt answers one question with cryptographic evidence: which bytes ran, where, when, with which inputs and outputs, and who vouches for it.

1. Layers of evidence

LayerWhat it provesSigned byVerified by
Statement (in-toto v1)bundle/payload digests, provenance, execution facts— (payload)verify-attest
DSSE envelopethe statement was produced by the operator keyoperator Ed25519 keyverify-attest, cosign, in-toto
Machine co-signaturethe receipt was produced on an enrolled hostmachine Ed25519 key (erebus enroll)verify-attest --strict
Machine certificatethe machine key belongs to <owner>registry keyverify-attest --strict --registry-key
Transparency-log proofthe receipt was published at position i of a signed treeregistry log key (+ RFC 3161 TSA)verify-attest --proof
Local trailreceipts form an append-only hash chainoperator key (prev hash inside the statement)verify-attest --chain

Every claim printed by a verifier is read from the signed statement, never from the unsigned display copies.

2. Receipt document (*.erebus.attest)

Pretty-printed JSON (2-space indent). The exact bytes on disk are the transparency-log leaf preimage and the local-trail hash input, so do not reformat a receipt.

{
  "spec_version": "2.0",
  "attestation_type": "erebus-execution",
  "artifact":   { "...": "display copy, see §4" },
  "provenance": { "...": "display copy, see §4" },
  "execution":  { "...": "display copy, see §4" },
  "statement":  { "...": "decoded in-toto statement, see §3" },
  "signature": {
    "algorithm": "ed25519",
    "key_fingerprint": "sha256:<hex(sha256(operator pubkey))>",
    "signature_hex": "<hex of the operator's DSSE signature>"
  },
  "envelope": {
    "payloadType": "application/vnd.in-toto+json",
    "payload": "<base64(JCS(statement))>",
    "signatures": [
      { "keyid": "sha256:<operator fingerprint>", "sig": "<base64>" },
      { "keyid": "sha256:<machine_id>",           "sig": "<base64>" }
    ]
  },
  "machine": {
    "machine_id": "sha256:<hex(sha256(machine pubkey))>",
    "public_key_hex": "<64 hex>",
    "certificate": {
      "machine_id": "sha256:…",
      "public_key_hex": "<64 hex>",
      "owner": "alice",
      "registry": "https://registry.example",
      "issued_at": "2026-09-17T10:00:00+00:00",
      "registry_key_id": "sha256:…",
      "registry_key_hex": "<64 hex>",
      "signature_hex": "<128 hex>"
    }
  }
}
  • envelope and machine are absent on spec 1.x receipts. machine is absent on 2.0 receipts produced by a host that never ran erebus enroll.
  • signature mirrors the operator entry of the envelope so 1.x consumers keep working. Verifiers must check that the mirror matches the envelope.

2.1 Canonical payload — JCS (RFC 8785)

payload = base64(JCS(statement)). JCS is applied to the JSON value of statement: object keys sorted by UTF-16 code units, no insignificant whitespace, strings serialized per RFC 8259, integers verbatim. Receipts contain no floating-point numbers (a float is a hard error at signing time).

2.2 Pre-Authentication Encoding — DSSE v1

Each signature covers

"DSSEv1" SP LEN(payloadType) SP payloadType SP LEN(payload) SP payload

with payload the decoded bytes and LEN the decimal byte length — exactly the DSSE protocol. Signature algorithm: Ed25519 (RFC 8032, ed25519-dalek strict verification).

2.3 Operator verification (verify-attest)

  1. envelope.payloadType == application/vnd.in-toto+json.
  2. base64decode(envelope.payload) == JCS(statement) — otherwise TAMPERED.
  3. Pick the trusted key whose fingerprint equals signature.key_fingerprint; the envelope must carry a valid signature by that key under keyid == fingerprint. Looking the key up by fingerprint (instead of “any trusted key signed something”) keeps a trusted machine key from satisfying the operator check.
  4. signature.signature_hex == hex(base64decode(sig)) of that entry.
  5. Re-derive the statement from artifact/provenance/execution with the statement’s own predicate_type (v1 or v2) and compare (§4).

2.4 Machine verification (verify-attest --strict)

  1. machine and envelope present (1.x receipts fail --strict).
  2. statement.predicate.execution.machine_id == machine.machine_id — the machine identity is part of the signed statement, so a machine block cannot be grafted onto another receipt.
  3. machine.machine_id == "sha256:" + hex(sha256(machine.public_key_hex)).
  4. The envelope carries a valid signature by the machine key.
  5. machine.certificate present, for the same key, and its signature verifies under a trusted registry key (--registry-key HEX or a .pub file in the trusted dir).

Certificate canonical bytes (signed by the registry key):

erebus-machine-cert/v1\n{machine_id}\n{public_key_hex}\n{owner}\n{registry}\n{issued_at}\n

The registry key that signs certificates is the transparency-log key (GET /log/key), so an auditor trusts exactly one registry key.

3. in-toto statement

{
  "_type": "https://in-toto.io/Statement/v1",
  "subject": [
    { "name": "bundle",  "digest": { "sha256": "<hex>" } },
    { "name": "payload", "digest": { "sha256": "<hex>" } }
  ],
  "predicateType": "https://erebus.dev/attestation/execution/v2",
  "predicate": { "…": "see §3.1" }
}

bundle is the SHA-256 of the whole .erebus file — this is what cosign verify-blob-attestation … <bundle> checks with --check-claims.

erebus 1.x wrote predicate_type; parsers accept both spellings and the 1.x signature bytes are rebuilt in the legacy form, so old receipts keep verifying. New receipts always use the in-toto key predicateType.

3.1 Predicate v2 — JSON Schema

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://erebus.dev/attestation/execution/v2",
  "title": "erebus execution attestation predicate",
  "type": "object",
  "required": ["builder", "layers", "execution", "artifact"],
  "additionalProperties": false,
  "properties": {
    "builder":  { "type": "string", "description": "erebus-cli version that built the bundle" },
    "author":   { "type": ["string", "null"] },
    "license":  { "type": ["string", "null"] },
    "layers": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["name", "kind", "sha256"],
        "properties": {
          "name":   { "type": "string" },
          "kind":   { "type": "string", "enum": ["runtime", "app", "model", "engine", "layer"] },
          "sha256": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }
        }
      }
    },
    "execution": {
      "type": "object",
      "required": ["attested_at", "machine_id_hash", "os", "arch"],
      "additionalProperties": false,
      "properties": {
        "attested_at":           { "type": "string", "format": "date-time" },
        "machine_id_hash":       { "type": "string", "description": "sha256(hostname‖os‖arch) — legacy, weak" },
        "machine_id":            { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$",
                                   "description": "fingerprint of the enrolled machine key that co-signed the envelope" },
        "os":                    { "type": "string" },
        "arch":                  { "type": "string" },
        "input_hash":            { "type": ["string", "null"], "description": "served call: sha256 of the proxied request body; batch run: sha256 of the launch vector (entrypoint then argv, NUL-joined); stdin is `stdin_hash`" },
        "output_hash":           { "type": ["string", "null"], "description": "served call: sha256 of the proxied response body; batch run: sha256(stdout digest \":\" stderr digest), captured on Linux isolation ≥ 2 and on macOS when ERE_ATTEST_KEY is set, else null" },
        "stdin_hash":            { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$", "description": "spec 2.1, batch run — sha256 of every byte relayed to a non-terminal stdin; omitted when stdin was a terminal, when the payload exited before stdin reached EOF (or stopped reading), or when not captured" },
        "egress": {
          "type": "object",
          "description": "spec 2.1, batch run — outbound policy the launcher enforced (ERE_EGRESS); omitted when the payload ran unconfined",
          "required": ["tcp_ports", "scope"],
          "properties": {
            "tcp_ports": { "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 65535 }, "description": "sorted; empty = no egress" },
            "scope":     { "enum": ["loopback", "any_address"], "description": "loopback: macOS Seatbelt, localhost:<port> only; any_address: Linux Landlock, the port on any host" }
          }
        },
        "exit_code":             { "type": ["integer", "null"] },
        "prev_attestation_hash": { "type": ["string", "null"], "description": "sha256 of the previous receipt bytes in the local trail" },
        "stop": {
          "type": "object",
          "description": "spec 2.1, §3.5 — omitted when this receipt is not a stop",
          "required": ["requested_at", "reaped_at", "signal"],
          "properties": {
            "requested_at": { "type": "string", "format": "date-time" },
            "reaped_at":    { "type": "string", "format": "date-time" },
            "signal":       { "type": "string", "enum": ["SIGTERM", "SIGINT"] },
            "kill_timeout": { "const": true, "description": "omitted when false — supervisor called kill()" }
          }
        },
        "approval": {
          "type": "object",
          "description": "spec 2.1, §3.6 — omitted when no human approval is attached",
          "required": ["decision", "subject_digest", "decided_at", "key_fingerprint", "signature_hex"],
          "properties": {
            "decision":        { "enum": ["allow", "deny"] },
            "subject_digest":  { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
            "decided_at":      { "type": "string", "format": "date-time" },
            "key_fingerprint": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" },
            "signature_hex":   { "type": "string", "pattern": "^[0-9a-f]{128}$" }
          }
        },
        "request": {
          "type": ["object", "null"],
          "required": ["method", "path"],
          "properties": {
            "method": { "type": "string" },
            "path":   { "type": "string" },
            "status": { "type": ["integer", "null"] },
            "tool_calls": {
              "type": "array", "minItems": 1,
              "description": "spec 2.1, §3.2 — omitted when the response carried none",
              "items": {
                "type": "object",
                "required": ["choice", "name", "arguments_hash"],
                "properties": {
                  "choice":         { "type": "integer", "minimum": 0 },
                  "id":             { "type": ["string", "null"] },
                  "name":           { "type": "string", "minLength": 1 },
                  "arguments_hash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }
                }
              }
            },
            "tool_calls_incomplete": { "const": true, "description": "spec 2.1 — omitted when false" },
            "tools_offered": {
              "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 },
              "description": "spec 2.1, §3.3 — function tool names the request offered"
            },
            "tool_history": {
              "type": "array", "minItems": 1,
              "description": "spec 2.1, §3.3 — calls the request replayed as history; items as tool_calls, choice 0"
            },
            "tool_results": {
              "type": "array", "minItems": 1,
              "description": "spec 2.1, §3.3 — results the request fed back",
              "items": {
                "type": "object",
                "required": ["id", "content_hash"],
                "properties": {
                  "id":           { "type": "string", "minLength": 1 },
                  "content_hash": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }
                }
              }
            },
            "request_tools_incomplete": { "const": true, "description": "spec 2.1 — omitted when false" }
          }
        },
        "rootfs_root":           { "type": ["string", "null"], "description": "Merkle root of the extracted rootfs (rootfs.merkle)" }
      }
    },
    "artifact": {
      "type": "object",
      "required": ["metadata_hash", "format_version", "flags"],
      "properties": {
        "metadata_hash":  { "type": "string" },
        "format_version": { "type": "integer" },
        "flags":          { "type": "array", "items": { "type": "string" } }
      }
    }
  }
}

Batch stdin (spec 2.1). With capture on, a non-terminal stdin is relayed by the launcher supervisor through a pipe and hashed. The digest is signed only when stdin reached EOF and every byte was delivered before the payload exited (500 ms grace), so stdin_hash always equals sha256 of the whole input an auditor holds: printf 'job' | ./agent.erebus signs sha256("job"). Its absence is not evidence of empty input. A terminal is left attached and never hashed.

Egress confinement (spec 2.1). ERE_EGRESS=PORT[,PORT…] (or none) in the launcher’s environment limits the payload’s outbound TCP to those ports; the intended value is the port of an erebus attest-tool http boundary, so the agent can reach its tools only through it. The launcher enforces it before exec and signs execution.egress; an unparsable value or a kernel that cannot enforce it stops the launch (exit 126 under capture), never an unconfined run.

PlatformMechanismEnforced
macOSSeatbelt network-outboundTCP and UDP only to localhost:<port> (scope: loopback)
Linux ≥ 6.7Landlock CONNECT_TCP (ABI ≥ 4) + seccompTCP connect only to the ports, on any address (scope: any_address); UDP/raw/packet sockets, TCP Fast Open, io_uring, x32 and foreign ABIs refused (EACCES)
Linux < 6.7—launch refused

Unix-domain sockets (local IPC) are not confined, and neither are child processes the payload spawns over stdio (an MCP stdio server started by the agent itself is outside the boundary).

Differences from v1 (…/execution/v1): execution.machine_id added. Everything else is byte-identical, which is why a single build_statement serves both predicate types.

3.2 Tool calls of a served call (spec 2.1)

A receipt from the attesting proxy records what the agent was told to do, not only what the model said: every tool call the engine returned is signed into execution.request.tool_calls, ordered by (choice, index).

FieldValue
choicechoices[].index of a chat completion; 0 for the Responses API
idengine call id (call_…; Responses API call_id), links the call to the tool message that answers it; null for a legacy function_call
namefunction name, in clear: an auditor must read which action ran
arguments_hashsha256 of the exact arguments string (stream fragments concatenated); JCS bytes when an engine sends an object
arguments_jcs_hashoptional: sha256 of the JCS form of the parsed arguments; absent, null or blank arguments count as {}; omitted when the arguments are not JSON or contain a float (JCS number formatting is not pinned across implementations). This is what a tool receipt is matched on (§3.4)

Recognised: chat completions message.tool_calls, streamed delta.tool_calls, legacy function_call, Responses API function_call output items (output[], response.output_item.done, response.completed deduplicated by index and id). The format is decided from the bytes alone — first non-whitespace byte { is one JSON document, anything else is read as SSE data: events — never from headers, so the list is a pure function of the body output_hash covers.

tool_calls_incomplete: true means the list may miss entries: compressed body (Content-Encoding other than identity), malformed JSON or event, body over 16 MiB (JSON) or event over 4 MiB (SSE), more than 256 calls, a name or id over 1 KiB, a call without a name. The extractor never guesses: an absent call is evidence of absence only when the flag is absent.

Both fields are omitted — not null — when unused, so a receipt without tool calls is byte-identical to 2.0 and says "spec_version": "2.0"; a receipt carrying either field says "2.1".

Auditor check (verify-attest <receipt> --response-body FILE), from signed data only:

  1. sha256(FILE) == execution.output_hash, else TAMPERED: response body mismatch.
  2. Re-extract the tool calls of FILE. Without the incomplete flag the result must equal the signed list and be complete; with it, every signed call must appear in the result. Otherwise TAMPERED: tool_calls mismatch — the launcher signed a list its own body contradicts.

Plaintext arguments are never in the receipt: the operator keeps them and arguments_hash proves they were not altered.

3.3 Tool context of the request, linked across the trail (spec 2.1)

An agent closes the loop in its next request: it replays the model’s calls as history and feeds each result back under the call id. The proxy indexes that request body — the one input_hash covers — into three signed lists:

FieldSource (chat completions / Responses API)
tools_offeredtools[].function.name, legacy functions[].name / tools[].name of type: function; built-in tools without a name are skipped
tool_historymessages[role=assistant].tool_calls and function_call / input[type=function_call], digested exactly like tool_calls
tool_resultsmessages[role=tool] → {id: tool_call_id, content_hash} / input[type=function_call_output] → {id: call_id, content_hash: sha256(output)}; content hashed as its string bytes, JCS otherwise

Only a body whose first non-whitespace byte is { is read; other bodies carry no tool context. request_tools_incomplete: true flags a compressed request, malformed JSON, a body over 16 MiB, a result without a call id (including legacy role: "function" results), a float in structured content, more than 256 entries or a label over 1 KiB. Same omission rule as §3.2: a request without tool context keeps the 2.0 bytes.

Auditor check (verify-attest <receipt> --request-body FILE): sha256("METHOD path\n" ‖ FILE) == execution.input_hash, else TAMPERED: request body mismatch; re-indexing FILE must give the signed lists (a superset when flagged incomplete), else TAMPERED: request tool fields mismatch.

Linkage (verify-attest --chain DIR, from signed data only, after every receipt verified). Walking the trail in order, each request is matched against the calls the engine issued in earlier receipts:

FindingMeaning
result_without_calla result was fed back for a call id the engine never issued in this trail — an injected result
history_without_callhistory replays a call id the engine never issued
history_alteredhistory replays an issued id with another name or arguments_hash — a rewritten conversation
result_rewrittena second, different content_hash for an id issued once
unoffered_toolthe engine called a tool absent from the request’s tools_offered

Each finding is reported once, at its first receipt. Issued calls are reported answered or pending. Two rules keep the report honest: an id the engine issued several times (some engines reuse call_0) never yields result_rewritten, and a missing-call finding after any response signed tool_calls_incomplete is counted unverifiable, not accused. A trail that starts mid-conversation shows its first replayed history as history_without_call. Findings are warnings; --require-tool-linkage turns them into a verification failure (exit 2).

3.4 Attested tool boundary (spec 2.1)

erebus attest-tool puts the tool itself behind a signing boundary and writes its own trail, one receipt per execution:

  • attest-tool http --listen A --upstream B: reverse proxy in front of a plain HTTP tool or an MCP server over Streamable HTTP (JSON or SSE answers). Every request is recorded.
  • attest-tool stdio … -- CMD ARGS: wraps an MCP server spawned over stdio. Newline-delimited JSON-RPC is relayed byte for byte. Each tools/call request with its response (matched by JSON-RPC id) is one receipt, with input_hash / output_hash the sha256 of the two messages without their newline, method STDIO and path tools/call.

The receipt names the tool (artifact.flags: ["tool"]). Its digests are those of the program file it runs, or of the JCS descriptor of the boundary ({kind, transport, upstream|command}), or of the exact bundle with --bundle. execution.request.tool_invocation is signed:

FieldValue
protocolmcp (JSON-RPC request) or http
nameMCP params.name; absent for plain HTTP (bind it with --tool-trail NAME=DIR)
arguments_jcs_hashsame canonical digest as §3.2: MCP params.arguments, or the HTTP request body (blank → {})
result_hashesevery form the result can be relayed in. MCP: JCS of result, of content, of structuredContent, and the raw sha256 of the text blocks joined by \n when content is all text. HTTP: raw sha256 of the body, plus its JCS digest when JSON
is_errorJSON-RPC error, MCP isError: true, HTTP status ≥ 400 or no response
incompletethe response was not observed (connection lost, compressed or oversized body)
call_idoptional: the engine’s tool call id the caller declared, from MCP params._meta["dev.erebus/call_id"] or the HTTP request header Erebus-Call-Id (_meta wins); trimmed, 1–256 bytes, otherwise absent

Execution matching (verify-attest --chain AGENT --tool-trail [NAME=]DIR …). Every tool trail is verified as a chain first: with the agent’s trusted keys by default (tool_trust: "shared", and the human output warns that one key can forge both trails), or with --tool-trusted-dir DIR only against the keys in DIR (tool_trust: "separate"). That directory must not hold any key the agent trail is trusted with; an overlap is refused, so the tool trail and the agent trail are provably signed by different keys. Then each call the engine issued is matched one-to-one, in chain order, to an unused tool execution:

  1. Bound: the execution whose call_id equals the call’s id. The tool must have run exactly that call (same name, same arguments_jcs_hash), otherwise the finding is execution_altered. A bound execution is never matched to any other call.
  2. Unbound: otherwise, an execution without call_id with the same arguments_jcs_hash and the same name (a nameless execution matches on arguments only).

Binding makes two identical calls distinguishable: without it, executions of identical calls that ran in another order are paired in chain order and can yield a spurious result_not_from_tool. A missing or non-existent directory is an error, never “nothing executed”.

FindingMeaning
result_without_executiona result was fed back for an issued call that no attested tool executed
result_not_from_toolthe fed-back content_hash is not one of the matched execution’s result_hashes
execution_without_calla tool execution no issued call accounts for (including one bound to an id the engine never issued), reported at its tool receipt
execution_alteredthe execution bound to this call ran another name or other arguments than the engine issued

The report counts executed, not_executed, verbatim, unverifiable, bound (matched by call_id) and altered. A call without arguments_jcs_hash (even when bound), an execution without arguments_jcs_hash or call_id, and an unmatched execution after a response signed tool_calls_incomplete are unverifiable, never accused. Findings carry the agent receipt that issued the call and the tool receipt. They are warnings; --require-tool-execution turns them into a failure (exit 2), independently of --require-tool-linkage.

3.5 Stop receipts (spec 2.1)

When erebus serve --attest-key receives SIGTERM or SIGINT after the engine is healthy, it appends one final receipt whose signed predicate carries only execution.stop:

FieldMeaning
requested_atISO 8601 UTC when the stop was first observed
reaped_atISO 8601 UTC when the engine child was reaped
signalSIGTERM or SIGINT
kill_timeouttrue when the supervisor called kill() (omitted when false)

A signal during startup (wait_healthy) aborts without a stop receipt. reaped_at is always ≥ requested_at. Spec version is 2.1.

The stub capture supervisor (ERE_ATTEST_KEY + pivot/macOS capture path) latches the same fields into the completion receipt when SIGTERM/SIGINT arrives during the run (kill_timeout: true, same semantics as serve).

3.6 Human approval (spec 2.1)

Optional oversight evidence (EU AI Act Art. 14). erebus approve signs:

erebus-approval/v1\n{decision}\n{subject_digest}\n{decided_at}\n

with an Ed25519 approver key (decision is allow or deny). The resulting JSON is attached with erebus attest --approval FILE into execution.approval. verify-attest --approver-key <hex|.pub> checks the signature and prints the approver fingerprint; a forged signature is TAMPERED (exit 2). Spec version is 2.1 when present.

3.7 Local witness checkpoint (R1 / DDIL)

While offline, a second Ed25519 witness key co-signs the local trail head. Design (trail head only — not a full Merkle tree offline):

FieldMeaning
originAbsolute path of the trail directory
sizeNumber of receipt files covered
rootsha256: of the head receipt file bytes (same digest as HEAD)
witnessed_atISO 8601 UTC when the witness signed
witness_idFingerprint of the witness verifying key
signature_hexEd25519 over the canonical bytes below

Canonical message:

erebus-witness-checkpoint/v1\n{origin}\n{size}\n{root_hex}\n{witnessed_at}\n{witness_id}\n

Stored as <trail>/HEAD.witness.json. CLI:

erebus witness keygen [--dir DIR]          # ~/.local/share/erebus/witness/witness.key
erebus witness seal --trail agent.attest.d
erebus verify-attest --chain DIR --witness-key <hex|.pub> [--require-witness]
erebus verify-attest receipt.attest --proof --witness-key …

--require-witness refuses a trail that grew past the sealed size / root (insertion during an outage). Without it, a historical seal still verifies and prints witnessed at T1; with --proof, also anchored at T2, requiring witnessed_at ≤ checkpoint.timestamp. A failed --push leaves the receipt in the trail and seals the head (store-and-forward).

4. Display copies and consistency

artifact, provenance and execution at the top level are convenience copies for humans and jq. They are not signed. A verifier rebuilds the statement from them and compares it to the signed statement; any difference is reported as TAMPERED: <block> mismatch. Keys absent from an older receipt (e.g. machine_id on 1.2) are treated as null, so 1.x receipts stay consistent.

5. Bare envelope (erebus attest --envelope FILE)

The envelope object alone, compact JSON. This is the interchange form:

erebus keygen --key-dir keys            # also writes <fp>.pem (SPKI)
erebus attest agent.erebus --key keys/<fp>.key --envelope agent.dsse.json
cosign verify-blob-attestation \
  --key keys/<fp>.pem \
  --signature agent.dsse.json \
  --type https://erebus.dev/attestation/execution/v2 \
  --insecure-ignore-tlog agent.erebus
# Verified OK

--insecure-ignore-tlog only means “this envelope was not uploaded to Rekor”; erebus receipts are anchored in the erebus transparency log instead (verify-attest --proof). Rekor upload is planned (P1). cosign refuses blobs over 128 MiB unless COSIGN_MAX_ATTACHMENT_SIZE is raised (e.g. 16GiB); bundles with embedded weights usually need it.

6. Compatibility matrix

Receiptverify-attest--strict--proofcosign
1.0 – 1.2 (pretty-JSON signature)✅ legacy path❌ STRICT: spec 1.x✅ if pushed❌
2.0, host not enrolled✅❌ STRICT: … not enrolled✅✅
2.0, enrolled host✅✅ with registry key✅✅
2.1 (tool fields, stdin_hash/egress, stop, or approval)✅ erebus after 0.6.1; ❌ TAMPERED on 0.6.1 and older, which rebuild the statement without the new fieldsas 2.0✅✅ (same v2 predicate URI)

7. Files and locations

PathPurposeMode
~/.local/share/erebus/machine/machine.key (or $ERE_MACHINE_DIR)machine seed, never rotated implicitly0600
…/machine/machine.cert.jsonregistry certificate0644
~/.local/share/erebus/witness/witness.key (or $ERE_WITNESS_KEY / $ERE_WITNESS_DIR)local trail witness seed (R1)0600
<trail>/HEAD.witness.jsonsigned trail-head checkpoint while offline0644
<bundle>.erebus.attestlatest receipt0644
<bundle>.erebus.attest.d/hash-chained trail0644
<receipt>.proof.jsontransparency-log proof (erebus-log-proof/v1)0644

8. Threat notes

  • A stolen operator key produces receipts that verify — but not --strict ones (no machine key) and not with a log proof predating the theft. Rotate the operator key; enrolled machines are unaffected.
  • A stolen machine key only helps an attacker who also holds an operator key; the registry certificate names the owner, and GET /machines lists every enrolled key so it can be revoked (revocation lists: P1).
  • The machine key is a software key. A TPM2-backed quote is on the roadmap (P1, optional, Linux only) and will be an extra field, never a requirement.
  • What tool linkage proves, and what it does not. It proves, per call id, what the engine asked for and what the agent reported back to it, and it exposes results nobody asked for, rewritten history and changed results (§3.3). Alone it does not prove the tool ran. With the tool behind attest-tool (§3.4) it also proves that every issued call reached a tool with exactly those arguments, that the result relayed to the model is the one the tool returned, and that the tool ran nothing the model did not ask for. Limits that remain: the boundary proves what crossed it, not the tool’s effect on the outside world (an HTTP tool that answers queued without sending the mail is not caught); a tool the agent calls around the boundary is not observed (execution_without_call cannot see it, result_without_execution can) unless the run is confined with ERE_EGRESS to the boundary’s port and the receipt shows egress — on Linux the port is enforced but not the address, and Unix sockets and stdio subprocesses stay open; two identical calls are told apart only when the caller declares the engine’s call id (call_id), and a declared id is the caller’s claim — it binds the execution to that call, it does not prove which process sent it; and both trails are signed by keys the verifier must trust. With the default shared trust a single compromised operator key forges both sides; with --tool-trusted-dir forging both needs two keys, which only helps if the tool operator’s key is actually held by someone else (another team, the tool vendor, another host). Separation proves different keys, not different owners.

Positioning

Who needs it

An AI operator that runs agents on machines it does not fully control — customer sites, edge fleets, kiosks, factories, vehicles, air-gapped networks — and must prove to an auditor what ran, where, when, and that the record was not edited afterwards.

SegmentWhy they need evidence, not logs
Defense and public administrationair-gapped by regulation; verification must work without calling a vendor; supply-chain traceability (SBOM, signatures) is already required
Industrial edge and vehiclesagents on hardware in the field; software update traceability (UNECE R156)
AI companies under the EU AI Acthigh-risk systems must log events automatically (Art. 12) and keep those logs (Art. 19, 26)

Out of scope for now: healthcare, and hobbyist developers.

The category

Execution provenance for AI agents. Build provenance (Sigstore, SLSA, in-toto) answers “who built this artifact from which source”. erebus answers the next question — “did that artifact run, on which machine, when, with what” — using the same standards, so a technical partner recognises it and a verifier can use cosign instead of trusting our code. See Comparison.

Why now

  • The EU AI Act’s record-keeping obligations for high-risk systems phase in over 2026–2027, and auditors are asking how logs are protected.
  • Agents increasingly run outside the vendor’s own cloud (on-prem, edge, customer VPCs), where the vendor’s dashboards are not evidence.
  • The standards to do it properly (DSSE, in-toto, transparency logs, RFC 3161) are mature and have tooling auditors can run themselves.

Why the launcher

A receipt is only as good as the place it is produced. erebus signs at the launcher: it verifies the bundle, starts the agent, and records the run, so there is no step where the operator could substitute other code and keep a clean record. The packaging (one sandboxed binary) exists to make that possible on any Linux host, without a daemon or root.

What erebus is not

  • Not a tracing or observability tool — it records digests, not content.
  • Not proof that the output is correct — see What erebus proves.
  • Not a replacement for Docker or Kubernetes — it can run inside them.
  • Not an inference host — we never run your models on our servers.

Business model

MIT client and server, forever. Paid: the hosted, anchored proof trail, fleet features and air-gapped enterprise support. The tiers are on the pricing page.

Comparison

erebus is execution provenance for AI agents: the Sigstore / SLSA / in-toto model — signed statements, DSSE, transparency log, cosign interop — applied to runs instead of builds, and produced by the launcher that starts the agent. The table compares categories rather than products; most teams will use erebus alongside several of them.

Question an auditor asksApp logsLLM tracing / observabilityBuild provenance (Sigstore, SLSA)TEE attestationerebus
Which exact code and weights ran?❌❌built artifact only✅ measured at launch✅ bundle, payload and rootfs hashes, signed per run
On which machine?hostname stringagent metadata❌✅ hardware identity✅ enrolled machine key co-signs, certified by the registry
When, provably?local clockvendor clockat build timeat attestation time✅ transparency log + RFC 3161 timestamp
With what input, producing what output?text, editable✅ in a database❌❌✅ digests per run and per inference call
Can the operator rewrite it afterwards?yesyes (their account)n/an/a❌ hash chain, public log, external timestamp
Verifiable offline by a third party?❌❌✅with vendor roots✅ verify-evidence, cosign, openssl ts
Proves the computation itself was faithful?❌❌❌partly (code identity, not semantics)❌ — see the boundary
Special hardware or cloud?nononoyesno

Where the others are better

  • Observability tools show content: prompts, tool calls, latencies. erebus stores digests, not content. Keep your tracing; erebus proves the trace refers to what really ran.
  • TEE attestation measures code identity in hardware, which a software key cannot match against a root-compromised host. erebus can carry a TEE quote in a receipt later; today its machine key is a software key.
  • Sigstore / Rekor is a public-good log with a large ecosystem. erebus envelopes already verify with cosign; Rekor upload is on the roadmap.

The one-sentence answer

“Why not just logs and tracing?” — Because the party being audited controls them. An erebus receipt is signed where the code runs, tied to the exact bytes and machine, chained, and anchored outside the operator’s reach, so a third party can verify it offline.

Quickstart

Prerequisites

  • Linux x86_64
  • Rust with musl target: rustup target add x86_64-unknown-linux-musl
  • zstd (available on most distributions)
  • C compiler (gcc or musl-tools) — required by backhand (squashfs)

Verify all prerequisites in one command:

make preflight      # or: erebus doctor

If anything is missing, auto-fix what can be installed:

erebus doctor --fix            # interactive (asks before each fix)
erebus doctor --fix --force    # non-interactive (for scripts and CI)

1. Build the launcher stub

make stub
# or: cd stub && cargo build --release --target x86_64-unknown-linux-musl

2. Build & run a Python web app

erebus build ./examples/hello-web -o hello-web.erebus

./hello-web.erebus
# Server listening on http://127.0.0.1:8080

Open http://127.0.0.1:8080 in your browser.

3. Build & run a Python app with dependencies

erebus build ./examples/bottle-web -o bottle-web.erebus

./bottle-web.erebus
# Server listening on http://127.0.0.1:8080

See the Python guide for details on dependencies and requirements.txt.

4. Inspect a .erebus

erebus inspect hello-web.erebus
name:            hello-web
runtime:         python
entrypoint:      python3 /app/app.py
layers:
  - runtime   11.9MB compressed / 54.0MB raw
  - app        0.0MB compressed /  0.0MB raw
integrity sha256: 4232327e...

5. Sign & verify

# Generate a keypair
erebus keygen --key-dir $XDG_DATA_HOME/erebus/keys

# Sign a .erebus
erebus sign hello-web.erebus --key $XDG_DATA_HOME/erebus/keys/<fingerprint>.key

# Trust the public key (~/.erebus/trusted-keys, or $ERE_TRUSTED_DIR);
# a signed bundle refuses to start without it
erebus trust $XDG_DATA_HOME/erebus/keys/<fingerprint>.pub

# Verify
erebus verify hello-web.erebus

6. Prove what ran

KEY=$XDG_DATA_HOME/erebus/keys/<fingerprint>.key

# Run with a receipt: the launcher signs it and appends it to the trail
ERE_ATTEST_KEY=$KEY ./hello-web.erebus
# or sign one for the bundle without running it
erebus attest hello-web.erebus --key $KEY
#  output: hello-web.erebus.attest
#  trail:  hello-web.erebus.attest.d/000001-….json

erebus verify-attest hello-web.erebus.attest --bundle hello-web.erebus
#  OK: attestation verified (key: …)
erebus verify-attest --chain hello-web.erebus.attest.d
#  OK: chain verified — 1 receipt(s)
erebus verify-attest hello-web.erebus.attest --bundle other.erebus
#  TAMPERED: bundle mismatch (attestation covers sha256:…, file is sha256:…)

# One file for an auditor
erebus evidence export hello-web.erebus -o evidence.zip
erebus verify-evidence evidence.zip --bundle hello-web.erebus

Receipts become third-party evidence once the host is enrolled (erebus enroll, machine co-signature, --strict) and receipts are pushed to a registry transparency log (--push, --proof); evidence export then needs the registry’s --log-key. See Auditing an erebus trail and the spec.

Debug

EREBUS_VERBOSE=1 ./hello-web.erebus
# Shows cold/warm start, extraction, and cache status

Incremental rebuild

The runtime layer (interpreter + stdlib + .so) is cached. Only app code is recompressed on subsequent builds:

# First build  : ~25s (Python)
# Rebuild (code): ~1s

Auditing an erebus trail

This guide is for the person who receives evidence: an auditor, a compliance officer, a customer’s security team. You do not need an erebus account, network access, or any trust in the operator who ran the agent.

You get one file, evidence.zip, produced on the operator’s side by:

erebus evidence export agent.erebus --log-key $REG -o evidence.zip [--include-bundle]

Export refuses to package a trail that does not verify, but you must not rely on that: everything below is re-checked on your machine.

1. Get the two keys through another channel

The pack carries the public keys that signed it. On their own they prove nothing — anyone can generate keys and sign a fabricated trail. Before opening the pack, obtain:

KeyWho gives it to youWhat it looks like
Operator public keythe operator (contract annex, signed email, call) — the value erebus keygen printed64 hex, or its sha256: fingerprint
Transparency-log keythe registry, at GET /log/key → public_key_hex64 hex

These two values are your only trust inputs. Write down where they came from.

2. Verify the pack

erebus verify-evidence evidence.zip \
  --bundle agent.erebus \
  --operator-key <operator public key hex> \
  --log-key <registry log key hex>
OK: evidence pack verified (erebus-evidence/1)
  bundle:     agent.erebus sha256:7ca8bb55… (file checked)
  receipts:   42, hash-chained, head sha256:360dcb37…
  signatures: 42/42 operator, 42/42 machine co-signed (enrolled)
  log:        42/42 inclusion proofs under signed checkpoints, 42 RFC 3161 anchor(s)
  files:      320 hashed — none missing, none extra
  operator    sha256:d830f417… (pinned)
              public key 5f1c02e9…
  registry    sha256:9d3a805b… (pinned)

--json prints the same report for your own tooling. Any failure exits with code 2 and names the file and the check that failed (TAMPERED: …).

What each line established, and how:

LineCheck
bundleSHA-256 of the file you hold equals the digest inside every signed receipt. Without --bundle (and without --include-bundle at export) only the digest is reported.
receiptsEach receipt names the SHA-256 of the previous one inside its signed statement: nothing was reordered, inserted or removed inside the trail.
signaturesDSSE signature by a pinned operator key; machine co-signature by a host key whose certificate is signed by the pinned registry key (--strict).
logEach receipt is a leaf of the registry’s Merkle transparency log: inclusion proof up to a checkpoint signed by the pinned log key.
filesEvery file listed in MANIFEST.json is present with its hash, and nothing else is in the zip. The manifest is only an index: editing a receipt and re-hashing the manifest is still refused by the receipt’s own signature and log leaf.
operator / registryEvery key in the pack is one you pinned. NOT PINNED means you skipped step 1: the report is then only self-consistent.

3. Check the timestamps without erebus

Each anchored receipt comes with the registry checkpoint it was logged under and the RFC 3161 response of an independent timestamping authority (TSA) over that checkpoint. erebus checks the token structurally; the TSA’s CMS signature is checked with stock OpenSSL and the TSA’s published CA chain:

unzip evidence.zip -d ev && cd ev
curl -sSO https://freetsa.org/files/cacert.pem        # the TSA named in the token
for t in anchors/*.tsr; do
  openssl ts -verify -in "$t" -data "${t%.tsr}.checkpoint" -CAfile cacert.pem
done
# Verification: OK
openssl ts -reply -in anchors/<receipt>.tsr -text | grep 'Time stamp'

anchors/<receipt>.checkpoint is byte-for-byte what the TSA hashed (erebus-log-checkpoint/v1, log origin, tree size, root); verify-evidence already refused the pack if it differs from the checkpoint signed in the proof. The TSA time is an upper bound: the receipt existed, in a log of that size, no later than that instant.

4. Check the signatures without erebus (optional)

Receipts are standard DSSE envelopes over in-toto statements, so Sigstore’s cosign verifies them against the bundle:

COSIGN_MAX_ATTACHMENT_SIZE=16GiB cosign verify-blob-attestation \
  --key ev/keys/operator/<fp>.pem \
  --signature ev/dsse/<receipt>.dsse.json \
  --type https://erebus.dev/attestation/execution/v2 \
  --insecure-ignore-tlog agent.erebus
# Verified OK

cosign refuses blobs over 128 MiB by default; bundles with embedded weights are larger, hence COSIGN_MAX_ATTACHMENT_SIZE. --insecure-ignore-tlog only means “not Sigstore’s Rekor log”: the erebus transparency log is checked in step 2. Key files are named after the first 16 hex digits of the fingerprint.

5. What you can and cannot conclude

With steps 1–3 green you can state, for each receipt: this exact bundle ran on this enrolled machine, for this operator, with these input and output digests and exit code, and the record existed unmodified no later than the TSA time. For a served model, each receipt is one inference call with the request and response digests.

You cannot conclude, from the pack alone:

  • that the output is correct or the agent behaved well — see What erebus proves;
  • what the inputs and outputs were — receipts hold digests. For a served call, input_hash / output_hash are the SHA-256 of the request and response bodies: ask the operator for them and hash them yourself. For a batch run, input_hash covers the launch arguments, stdin_hash (when present) the whole piped stdin, and output_hash combines the stdout and stderr digests;
  • that no later execution was left out. The chain proves nothing was removed before the head; runs after the head are only visible in the transparency log. To close that gap, ask the registry for the current checkpoint (GET /log/checkpoint) and the operator’s records (erebus attest-history) for the period you audit, and compare counts;
  • that the host was not root-compromised while running (the machine key is a software key; see the boundary page).

Reference

  • Pack layout: trail/ (receipts, proofs, HEAD), anchors/ (.tsr, .checkpoint), dsse/, keys/operator/, keys/registry/, MANIFEST.json, README.txt, optional bundle/.
  • Receipt format: Execution attestation — spec 2.0.
  • Per-receipt verification: erebus verify-attest <receipt> --strict --proof --log-key <hex> --bundle <file>.

Building a Python App

Expected structure

erebus detects a Python app by the presence of an entry point at the root: app.py, main.py, server.py, __main__.py, manage.py, or an extensionless script whose shebang is python / python3 (for example #!/usr/bin/env python3). Marker files pyproject.toml, setup.py, and requirements.txt also select the Python runtime.

my_app/
  app.py            ← entry point, auto-detected
  ...               ← other modules, templates, assets

Build

erebus build ./my_app -o my_app.erebus

The builder:

  1. detects the python runtime and entrypoint (/app/app.py);
  2. embeds the build machine’s python3 interpreter;
  3. embeds the stdlib (/usr/lib/pythonX.Y);
  4. resolves .so dependencies via the ELF analyzer (libc, etc.);
  5. compresses and assembles the .erebus.

Environment variables

The builder injects PYTHONUNBUFFERED=1 (real-time logs) and PYTHONDONTWRITEBYTECODE=1 by default. Your app reads its own variables normally:

import os
PORT = int(os.environ.get("PORT", "8080"))
PORT=9000 ./my_app.erebus

Third-party dependencies (site-packages)

erebus embeds third-party dependencies from, in order of merge:

  1. a vendored site-packages/ directory at the app root (copied with the app);
  2. a virtualenv .venv/ or venv/ at the app root → its lib/pythonX.Y/site-packages (or Lib/site-packages on Windows layouts).

The full .venv / venv tree is not copied into the payload (it is on the skip list with .git, __pycache__, …). Only the install tree is staged into /app/site-packages.

my_app/
  app.py
  .venv/                ← skipped as a tree; site-packages extracted
    lib/python3.12/site-packages/
      bottle.py

The builder:

  • merges those packages into /app/site-packages in the rootfs;
  • declares PYTHONPATH=${ROOTFS}/app/site-packages in metadata (the ${ROOTFS} token is resolved by the launcher — see Launcher);
  • the stub also injects that PYTHONPATH at runtime when app/site-packages exists, so vendored-only apps work even without a prior metadata entry;
  • runs the ELF analyzer on .so files from C extensions (numpy, pillow…) to embed their system dependencies.

If a .venv / venv is present without any Python entrypoint and without requirements.txt, the build fails with a clear error (add app.py or requirements.txt).

The example examples/site-packages-only has a vendored site-packages/ and no requirements.txt. examples/bottle-web can use either a vendored tree or pip at build time.

erebus build ./examples/site-packages-only -o /tmp/sp.erebus
./sp.erebus   # → hello from vendored site-packages

Requirements.txt → pip install at build time

If your app has a requirements.txt with non-empty content, the builder automatically pip-installs the dependencies into the rootfs site-packages layout (and they remain importable via the embedded interpreter paths and/or /app/site-packages):

erebus build ./my_app -o my_app.erebus
# [erebus] pip install: ./my_app/requirements.txt → …/site-packages

Supported entrypoints

The builder auto-detects the entrypoint in this priority order:

  1. Django — manage.py → python3 /app/manage.py runserver 0.0.0.0:8000
  2. Streamlit — streamlit in requirements → streamlit run /app/app.py --server.port 8501 --server.address 0.0.0.0
  3. FastAPI/ASGI — uvicorn/gunicorn in requirements or pyproject.toml → uvicorn module:app --host 0.0.0.0 --port 8000
  4. PEP 621 [project.scripts] — start = "..." in pyproject.toml
  5. Python module — pyproject.toml with tool.fastapi.entrypoint or package with __main__.py → python3 -m module
  6. Named script — first of app.py, main.py, __main__.py, server.py → python3 /app/<script>
  7. Shebang script — extensionless file with #!/usr/bin/env python3 (or python) → python3 /app/<name>

Unsupported bundles

  • PyInstaller onefile and .pyz zipapps are detected and refused (fail closed). Package the Python sources (or a plain native binary) instead of re-wrapping a frozen bundle inside erebus.

Asyncio / multiprocessing under the sandbox

  • asyncio (stdlib event loop, asyncio.run, simple gather) works under the default sandbox. See examples/asyncio-smoke.
  • multiprocessing / heavy fork + shared memory is a known limitation under Linux isolation ≥ 2 (pivot_root + namespaces): some spawn methods fail or hang. Prefer threads or asyncio for packaged agents; do not claim full multiprocessing support.

Current limitations

  • Cross-distro portability: see Isolation — strongest when isolation uses user namespaces + pivot_root.

Packaging a Native Binary

erebus can package a pre-compiled native binary into a self-extracting executable. No runtime embedding is needed — just the binary and its shared libraries (when dynamic).

Detection

The builder detects a native binary when exactly one ELF, PE (.exe), or Mach-O (thin or universal) executable is found at the app root and no Python runtime is detected. Two or more native binaries (for example PE + ELF) is an error: keep a single entry binary or add a Python entrypoint.

my-binary/
  my-app          ← ELF / PE / Mach-O (auto-detected)
  libfoo.so       ← optional shared library (ELF builds)

Build

erebus build ./my-binary -o my-binary.erebus

The builder:

  1. detects the binary via magic bytes (\x7fELF, MZ, Mach-O);
  2. resolves shared library dependencies via the ELF analyzer (Linux ELF);
  3. packages the binary + libraries into the app layer;
  4. compresses and assembles the .erebus.

How it works

No runtime interpreter is embedded. The launcher:

  1. extracts the binary and its .so dependencies to the cache;
  2. sets LD_LIBRARY_PATH to include the extracted library directories (Unix);
  3. execvps the binary directly.

Shared library resolution

The Rust ELF analyzer reads DT_NEEDED, DT_RPATH, and DT_RUNPATH entries from the ELF header to find all transitive shared library dependencies. No host ldd is required.

erebus inspect my-binary.erebus
# layers:
#   - app    2.1MB compressed / 8.4MB raw  (binary + .so files)

Cross-compilation

Native binaries cannot be cross-compiled by erebus — the binary must already be built for the target architecture. Use erebus build --target aarch64 only for interpreted runtimes (Python) where the interpreter can be downloaded for the target arch.

Known limitations

  • Stub vs payload ABI: the Linux launcher stub is a musl static-pie. A stale glibc-linked stub (or mixing a glibc payload interpreter with the wrong stub) fails at launch — rebuild with make stub / --target $(uname -m)-unknown-linux-musl. See AGENTS.md.
  • Windows packaging of PE is build-host detection only; the production launcher path targeted in CI is Linux. Do not assume PE redistributables are supported end-to-end.
  • Mach-O is detected for macOS agent binaries; sandbox/isolation differs from Linux pivot_root.
  • Statically linked binaries (no .so dependencies) work — the smallest possible .erebus.
  • Binaries with unusual loader paths (/lib/ld-linux.so.2) may need the loader embedded in the rootfs.
  • PyInstaller onefile and .pyz zipapps are refused at detect time — package the Python sources (or a plain native binary) instead.

Dependency Detection

This is where the builder’s complexity lives — detecting what the app needs and bundling exactly the right files.

Detection pipeline

The Rust builder (erebus-core) combines runtime detection with package manager detection to resolve dependencies:

app directory ──→ detect.rs ──→ runtime + entrypoint
                  pkgmgr.rs ──→ package manager + install strategy
                  assembly  ──→ copy interpreter + libs + app into rootfs

Runtime detection (detect.rs)

Identifies the runtime and resolves the entrypoint:

  • app.py / main.py / server.py / __main__.py / pyproject.toml / requirements.txt / setup.py → Python
  • ELF executable → Binary

Package manager detection (pkgmgr.rs)

Detects which package manager an app uses:

RuntimePriority order
Pythonuv > poetry > pipenv > pip

ELF dependency resolution

For dynamic binaries, the builder reads ELF64 program headers directly (without calling ldd):

  1. Iterates Program Headers to find PT_DYNAMIC and PT_INTERP
  2. Extracts DT_NEEDED entries (required libraries) and DT_RUNPATH (search paths)
  3. For each library, searches standard paths (/lib, /usr/lib, /lib64, etc.), LD_LIBRARY_PATH, and DT_RUNPATH
  4. Recursively resolves dependencies of each found .so
  5. Deduplicates entries pointing to the same real file via different symlinks
your binary ─ELF→ libc.so.6, libssl.so.3, ..., ld-linux-x86-64.so.2

No ldd required on the host machine — works with any Rust toolchain, ideal for cross-compilation and self-hosting.

What static analysis does NOT see

Even with runtime detection, some dependencies remain invisible to static analysis:

ctypes.cdll.LoadLibrary("libcuda.so.1")              # dynamic dlopen
importlib.import_module(plugin_name)                 # dynamically loaded plugin

No static tool can reliably find these: you need to understand the code, not just read its symbol table.

Configuration override

Users can explicitly declare external binaries, dlopen libs, required env vars, and data files in an erebus.toml. This is the safety net when auto detection is not enough.

# erebus.toml (target)
[deps]
binaries = ["ffmpeg", "convert"]
libraries = ["libcuda.so.1"]   # optional, GPU
[env]
required = ["DATABASE_URL", "SECRET_KEY", "PORT"]

Architecture

erebus is split into 4 layers with clear interfaces between them, allowing each layer to evolve independently (e.g. switching from tar extraction to squashfs+mmap) without rewriting everything.

┌──────────────────────────────────────┐
│            CLI (erebus)                │  build · run · inspect · clean
├──────────────────────────────────────┤
│            Builder                    │  Analyzer + Packager
│   ┌──────────────┬──────────────┐     │
│   │   Analyzer   │   Packager   │     │
│   └──────────────┴──────────────┘     │
├──────────────────────────────────────┤
│            Runtime (launcher)         │  self-read · cache · executor
│   ┌──────────────┬──────────────┐     │
│   │    Cache     │   Executor   │     │
│   └──────────────┴──────────────┘     │
├──────────────────────────────────────┤
│            Format .erebus            │  shared binary spec
└──────────────────────────────────────┘

Each layer only knows the one below it. The .erebus format is the shared contract: the builder (Rust) writes it, the launcher (Rust) reads it. As long as the format is respected, both sides evolve independently.

Architecture diagram (initial sketch)

The diagram below is the target architecture envisioned for end of Phase 2/3, not the current MVP state.

Reading the diagram

The diagram flows top to bottom:

  1. CLI (erebus ./my_app) — four commands: Build · Run · Inspect · Clean. This is the user surface.

  2. Builder — two sub-components:

    • Analyzer: Rust ELF parser (DT_NEEDED, DT_RUNPATH, transitive resolution), runtime detection, and hidden dependency detection (subprocess, dlopen) — see the AI annotation on the right.
    • Packager: builds the rootfs, compresses with zstd, assembles the final .erebus.
    • The AI annotation (top right) marks the intended role of AI: analyze source code, detect subprocess and dlopen calls invisible to static analysis. This is the project’s differentiator — see Dependency detection.
  3. .erebus Format — the central layer: ELF Launcher · zstd Payload · JSON Metadata · Magic + SHA-256. This is the file format described in reference.

  4. Runtime — two sub-components:

    • Cache: ~/.cache/erebus/{sha256}/, single extraction, flock() for concurrent access, LRU cleanup.
    • Executor: Linux user namespaces · pivot_root (isolation) · seccomp filter · LD_LIBRARY_PATH fallback.
  5. Goal (bottom): single binaries · warm start < 100 ms · 0 dependencies.

Gap between target diagram and current MVP

The diagram describes the ambition. Here is the honest current state:

ElementTargetCurrent
CLI build/run/inspect✅✅ implemented
Rust ELF analyzer✅✅ implemented (replaces host ldd)
Dockerfile dependency detection✅⚠️ removed (Python CLI only)
Python AST scanner (subprocess)✅⚠️ removed (Python CLI only)
Dependency fetcher (staging)✅⚠️ removed (Python CLI only)
PATH injection (bundled binaries)✅✅ implemented (rootfs usr/bin prepended)
AI analyzer (hidden deps)✅⏳ Phase 3
ELF + zstd + meta + SHA-256 format✅✅ implemented
{sha256} cache + atomic extraction✅✅ implemented (flock() included)
Level 0 executor (LD_LIBRARY_PATH + PATH)✅✅ implemented
User namespaces + pivot_root + seccomp✅✅ implemented (Phase 2)
Ed25519 signatures✅✅ implemented (Phase 2)
warm start < 100 ms✅⏳ (currently limited by embedded runtime boot, not by erebus)

Design note. The diagram places isolation (namespaces, seccomp) at the core of the runtime. In practice we chose to start at level 0 (LD_LIBRARY_PATH, no isolation) because isolation is a feature, not the core value proposition. The value is “one file, it runs”. Isolation is added incrementally, without changing the format.

The full flow in one picture

DEV MACHINE                          TARGET MACHINE

my_app/
  app.py
  requirements.txt   →  erebus build  →   my_app.erebus   →  ./my_app.erebus  →  it runs
+ python3                              (1 file)
+ .so libs

erebus build does the hard work once. The end user only sees a single file.

Self-reconstruction (SISR, opt-in)

The architecture above is the static model. An optional extension adds a third responsibility inside the binary — the embedded SISR engine — which lets the binary update itself from signed deltas, with no toolchain on the target machine. Both models ship in the same file shape and behave identically on a default launch; SISR only engages on an explicit update.

+-----------------------------------------------------------------+
|                       APP.EREBUS (ELF)                         |
+-----------------------------------------------------------------+
|  1. Entrypoint Launcher  --> Bootstrap + runtime isolation       |
+-----------------------------------------------------------------+
|  2. Embedded SISR Engine --> Verify, fetch & assemble (dormant)  |
+-----------------------------------------------------------------+
|  3. Payload (SquashFS)   --> Application code & assets           |
+-----------------------------------------------------------------+

The reconstruction flow replaces the “redistribute a new binary” step with a self-rebuild on the target:

[ Binary v1.0 ] ──( ./app.erebus update )──▶ [ Interrogate remote manifest ]
                                                │
                                                ▼
                                       [ Download deltas / chunks ]
                                                │
                                                ▼
                                   [ Validate Ed25519 signatures + hashes ]
                                                │
                                                ▼
[ Binary v1.1 ] ◀──( Rebuild & atomic swap )───┘

Every step is verified: signature before anything runs, anti-rollback index before applying, per-block SHA-256 and a Merkle commitment before commit. An interruption leaves v1.0 intact.

LayerStatic .erebusSISR .erebus
Launchersamesame (unchanged)
SISR engineabsentembedded, dormant by default
Payload (SquashFS)samesame
Update pathrebuild + redistributeself-rebuild from signed deltas

SISR is described in detail in the SISR overview, the delta manifest spec, and the incremental updates guide.

.erebus Format

A .erebus file is a valid ELF executable with a compressed payload, JSON metadata, and a footer at the very end of the file. The launcher reads itself via /proc/self/exe, seeks to the end, reads the footer, and finds everything else from the offsets stored there.

offset 0    ┌─────────────────────────┐
            │   ELF launcher (musl)   │  ← kernel executes this
            ├─────────────────────────┤
payload_off │   payload = zstd(tar)   │  ← compressed rootfs
            ├─────────────────────────┤
meta_off    │   metadata (JSON utf8)  │  ← entrypoint, env, runtime...
            ├─────────────────────────┤
EOF - 92    │   FOOTER (92 bytes)     │  ← read backwards by launcher
            └─────────────────────────┘

The format is versioned (format_version in the footer). v1 uses a single monolithic payload; v2 splits the payload into layers; v3 adds Ed25519 signatures; v4 adds AES-256-GCM encryption; v5 adds SquashFS layer support. The launcher reads all versions.

All integers are little-endian. The v3 footer is a 92-byte block: an 8-byte prefix (sig_offset) followed by the 84-byte v2-compatible core.

FieldOffsetTypeSizeDescription
sig_offset0u648absolute offset of signature block (0 if unsigned)
magic8bytes5"ERE\x01"
format_version13u81format version (3, 4, or 5)
arch14u810x01=x86_64, 0x02=aarch64
flags15u81bit0=signed, bit1=encrypted
payload_offset16u648absolute offset of payload
payload_csize24u648compressed size of all layers
payload_usize32u648v2/v3: unused; v4/v5: crypto_suite (0=none, 1=AES-256-GCM)
payload_sha25640bytes32SHA-256(payload ‖ metadata)
meta_offset72u648absolute offset of metadata
meta_size80u648metadata size in bytes
footer_magic88u3240xBEEFCAFE end sentinel

The spec is implemented in erebus-core/src/format.rs — the single source of truth, shared by the launcher (stub) and the CLI. There is no separate stub/src/format.rs.

Constraint: The Linux kernel requires ELF magic (\x7fELF) at offset 0 to execute the file. We cannot put our own bytes at the start without breaking chmod +x && ./my_app.erebus.

Options considered:

  1. Custom header before ELF — rejected. The kernel would refuse to execute the file. binfmt_misc could work but requires root on every target machine.
  2. Embed metadata inside ELF sections — rejected. The kernel loads ELF sections into memory; our metadata would consume address space and confuse debuggers.
  3. Footer at end of file — chosen. Used by makeself, AppImage, and self-extracting .exe. The launcher opens itself via /proc/self/exe, seeks from the end, reads the fixed-size footer, and finds everything from the stored offsets.

Design decision: v3 8-byte prefix for signatures

Constraint: Ed25519 signatures need to be stored between metadata and the footer. But the footer is at a fixed position (EOF-84 for v2), and v2 launchers read exactly 84 bytes from the end. We cannot change the footer size without breaking backward compatibility.

Options considered:

  1. Grow the footer to 92 bytes (add sig_offset field) — rejected. A v2 launcher reading 84 bytes would see truncated data and likely crash or silently misparse.
  2. Append the signature block after the footer — rejected. The launcher reads backwards from EOF; data after the footer is invisible to it.
  3. 8-byte prefix before the 84-byte core — chosen. The last 92 bytes are: [8-byte sig_offset][84-byte v2 core]. A v2 launcher reads the last 84 bytes and sees valid v2 data (the prefix is invisible). A v3 launcher reads 92 bytes and picks up sig_offset from the prefix. No breaking change.
v2 launcher reads:          v3 launcher reads:
   ┌──────────────┐            ┌──────────────────────┐
   │ 84-byte core │            │ 8B prefix │ 84B core │
   └──────────────┘            └──────────────────────┘
   EOF-84 → EOF                EOF-92 → EOF

Integrity hash

The payload_sha256 field stores:

SHA-256(compressed_payload_bytes ‖ metadata_json_bytes)

The launcher recomputes this hash on every cold start and compares it to the stored value before extracting anything. On mismatch: exit(1), nothing written to disk.

For signed files (v3+), the digest the signature covers also includes the footer’s full on-disk form (the 8-byte sig_offset prefix + the 84-byte core):

Ed25519_sign(SHA-256(payload ‖ metadata ‖ footer_bytes), private_key)

The footer must be covered because it decides whether the signature is ever consulted: a signature over payload ‖ metadata alone would let an attacker downgrade the file to v2, clear FLAG_SIGNED, and recompute the SHA-256 — the signature would be silently skipped. The launcher also rejects inconsistent states (sig block without the flag, or the flag without a block) and v2 files carrying a leftover signature block.

See Security for why SHA-256 alone is insufficient.

Signature block (v3, 68 bytes)

Inserted between metadata and footer when flags & FLAG_SIGNED:

FieldTypeSizeDescription
sig_sizeu32le4always 64 (Ed25519 sig)
signaturebytes64Ed25519 signature

The footer’s sig_offset field points to the start of this block.

JSON metadata

{
  "name": "hello-web",
  "erebus_version": "0.1.0",
  "created": "2026-06-23T12:00:00Z",
  "runtime": "python",
  "isolation": 0,
  "entrypoint": ["/usr/bin/python3.12", "/app/app.py"],
  "env": { "PYTHONUNBUFFERED": "1" },
  "layers": [...]
}
  • entrypoint: argv executed by the launcher. Paths are relative to the rootfs — the launcher resolves them to real cache paths at exec time.
  • env: additional variables. The launcher injects LD_LIBRARY_PATH separately and resolves ${ROOTFS} tokens to the real cache path.
  • isolation: 0 = LD_LIBRARY_PATH, 1 = chroot (skipped), 2 = user namespaces + pivot_root.
  • layers: array of compressed layer objects (v2+ only).

Layers (v2+)

The payload is a sequence of layers, each an independent compressed blob. Layers stack at extraction (later layers overwrite earlier ones):

[ stub ][ runtime layer ][ app layer ][ metadata ][ sig? ][ footer ]
         ^                 ^
         python+stdlib+.so app code + site-packages
         stable            volatile

v2–v4 uses zstd(tar) blobs. v5 uses SquashFS images (better compression ratio). The layer format is indicated by the "payload_format" field in metadata: "zstd-tar" (default) or "squashfs".

The layer table lives in the JSON metadata:

"layers": [
  {"kind": "runtime", "offset": 614960, "csize": 6710886, "usize": 26953728,
   "sha256": "168a7279b815..."},
  {"kind": "app",     "offset": 7325846, "csize": 12044,   "usize": 204800,
   "sha256": "9e61cd65ed9e..."}
]

offset is the absolute byte offset in the file. Each layer’s SHA-256 (of the compressed blob) is a stable cache key — if the content doesn’t change, its extraction is reusable.

Why layers: incremental rebuild

The runtime layer (interpreter + stdlib + .so) is independent of app code. Editing app.py doesn’t change it. On rebuild, the builder reuses it from the build cache (~/.cache/erebus/build/) — no recompression. Only the app layer is rebuilt.

# First build: ~25s (compressing runtime layer, ~54 MB)
$ erebus build ./my_app
[erebus] wrote my_app.erebus (7.1MB) in 25.1s

# Rebuild after code change: ~1s (runtime layer reused)
$ erebus build ./my_app
[erebus] wrote my_app.erebus (7.1MB) in 1.2s

Two apps sharing the same runtime share the same runtime layer in the build cache — the second app also builds in ~1 s.

Version evolution

The footer is versioned (format_version). A launcher rejects a file with a version higher than it understands:

$ ./old-erebus new-format.erebus
[erebus] error: unsupported .erebus format version (binary newer than launcher)
VersionChanges
v1Monolithic zstd(tar) payload
v2Layered payload (runtime + app), incremental rebuild
v3Ed25519 signatures (92-byte footer with sig_offset)
v4AES-256-GCM payload encryption (crypto_suite in footer)
v5SquashFS layer support (payload_format in metadata)

Reserved fields (flags, sig_offset) allow extension without breaking compatibility. Ed25519 signatures are inserted between metadata and footer with a flags bit and a dedicated offset — v2 files remain readable.

The Launcher (stub)

The launcher (“stub”) is the small program embedded at the head of every .erebus. It’s the ELF the kernel runs when the user executes ./my_app.erebus.

  • Code: stub/src/main.rs + erebus-core/src/format.rs (shared format parser)
  • Language: Rust, statically compiled for x86_64-unknown-linux-musl → zero dynamic dependencies, runs everywhere.
  • Size: ~600 KB (musl static, opt-level=z, LTO, strip, panic=abort).

Why Rust + musl?

Rust: the launcher executes before any verification, manipulates binary files and makes system calls. A memory error here = vulnerability. Rust eliminates use-after-free, buffer overflows and null dereferences by construction, with no runtime or GC.

musl: glibc is dynamically linked and its versions vary between distros (GLIBC_2.35 not found…). A static musl binary has zero dynamic dependencies — it runs on any Linux kernel ≥ 3.8. This is exactly what erebus must guarantee for its own launcher.

ldd stub/target/x86_64-unknown-linux-musl/release/erebus-stub
# → statically linked

Execution flow

./my_app.erebus
   │
   1. open("/proc/self/exe")          ← reliable self-location
   2. read footer (last 92 bytes for v3), validate magic
   3. read JSON metadata
   4. read payload, verify SHA-256         ← integrity
   5. cache hit? → ~/.cache/erebus/{sha256}/.ready
        yes → reuse
        no  → extract (zstd → tar) to tmp, atomic rename()
   6. build argv + env (inject LD_LIBRARY_PATH + PATH)
   7. execvp(entrypoint)              ← replaces the process

Why /proc/self/exe and not argv[0]?

argv[0] is caller-controlled: a malicious process could launch the launcher with a fake argv[0]. /proc/self/exe is provided by the kernel and always points to the real running executable. We always read the correct file.

Decompression: why ruzstd and not the zstd crate?

The zstd crate binds the C library libzstd, requiring a C compiler for the musl target. ruzstd is a 100% Rust zstd decompressor: no C toolchain, trivial static musl build. The launcher only decompresses — that’s exactly ruzstd’s scope. Compression (more CPU-intensive) stays on the builder side via the zstd CLI.

The ${ROOTFS} token in the environment

The builder doesn’t know in advance where the cache will be materialized (~/.cache/erebus/{sha256}/rootfs). To still declare paths (e.g. PYTHONPATH), it writes the ${ROOTFS} token in the manifest’s environment variables, and the launcher replaces it with the real path at exec time:

manifest :  PYTHONPATH = ${ROOTFS}/app/site-packages
execution:  PYTHONPATH = /home/user/.cache/erebus/f342.../rootfs/app/site-packages

LD_LIBRARY_PATH doesn’t need this token: the launcher computes it directly from the lib* directories present in the rootfs.

PATH injection for bundled binaries

The launcher also injects PATH with rootfs binary directories, using the same logic as LD_LIBRARY_PATH:

  • Pivot mode (isolation ≥ 2): PATH = usr/bin:bin:usr/local/bin
  • Non-pivot mode: PATH = {rootfs}/usr/bin:{rootfs}/bin:{rootfs}/usr/local/bin:{existing_PATH}

Bundled entries go before the existing PATH. This is intentional: if the builder packages ffmpeg 6.1 into the rootfs but the host has ffmpeg 4.4, the app uses the bundled version. The same priority logic applies to LD_LIBRARY_PATH — rootfs libs come first.

CWD handling after pivot_root

When isolation level 2 is active, after pivot_root + umount2, the process’s current working directory still points to the old root — which was just detached. The launcher calls set_current_dir("/") just before execve() so the app process starts in the new root, ensuring correct resolution of relative paths and symlinks.

Concurrent access: flock()

If two instances of the same .erebus start simultaneously on a cold cache, an exclusive flock() (on ~/.cache/erebus/{hash}.lock) guarantees only one performs the extraction; the other waits and then finds the cache ready. Extraction is already atomic via rename() — flock simply avoids duplicated work.

What the launcher does at runtime (annotated)

Excerpt from main.rs — the sequence is deliberately linear and readable:

// 1. Locate ourselves reliably (not argv[0], which is caller-controlled).
let mut exe = File::open("/proc/self/exe")?;
let footer = Footer::read_from(&mut exe)?;

// 2-3. Metadata then payload, with integrity verification.
let meta: Metadata = serde_json::from_slice(&meta_bytes)?;
verify_sha256(&payload, &footer.payload_sha256)?;

// 4. Cache: extract once, atomically.
if !ready_marker.exists() {
    extract_atomic(&payload, &cache_root, &rootfs)?;
}

// 5. Replace the current process with the app.
exec_app(&meta, &rootfs)  // uses execvp for single-service, execve in supervisor

The Builder

The builder analyzes an application and produces the .erebus. It’s written in Rust as part of erebus-core — zero Python dependency at runtime.

  • Code: erebus-core/src/ (assembly, compress, detect, pkgmgr, tar, etc.)
  • CLI: erebus-cli/src/commands/build.rs orchestrates the build pipeline

The three steps

1. Analysis — detect.rs + pkgmgr.rs

  • detect.rs detects the runtime and resolves the entrypoint:
    • app.py / main.py / server.py → python;
    • package.json → node;
    • a single ELF executable → native binary.
    • Returns a RuntimePlan: interpreter to embed, entrypoint (relative to rootfs), cwd, env, extra directories (e.g. Python stdlib).
  • ELF analysis is built into detect.rs — reads ELF64 program headers directly (without calling ldd): reads PT_DYNAMIC headers, extracts DT_NEEDED, DT_RUNPATH, PT_INTERP, and resolves transitive dependencies in standard system search paths (/lib, /usr/lib, /lib64, etc.). Includes the dynamic loader ld-linux.

2. Rootfs construction — _build_runtime_layer()

Assembles a mini-filesystem containing exactly what’s needed:

rootfs/
  app/                          ← application code
  usr/bin/python3.12            ← interpreter
  usr/lib/python3.12/           ← stdlib
  usr/lib/x86_64-linux-gnu/     ← .so files (libc, etc.)
  lib64/ld-linux-x86-64.so.2    ← dynamic loader
  etc/{passwd,group,hosts,resolv.conf,nsswitch.conf}

Key point: we preserve the absolute tree of copied files (/usr/lib/... → rootfs/usr/lib/...). This lets the embedded Python interpreter find its stdlib via landmark detection relative to its own path.

For cross-compilation (--target aarch64), a vendored Python from python-build-standalone is downloaded and .so resolution is skipped (no host libs to resolve for a different arch).

3. Layer splitting + compression — build()

The builder constructs two layers (v2 format):

  • runtime layer (_build_runtime_layer): interpreter + stdlib + .so + /etc. Independent of app code.
  • app layer (_build_app_layer): app code + site-packages. Small and volatile.

Each layer is compressed with either zstd -19 (default) or mksquashfs (--squashfs flag, v5 format, better compression ratio).

Build cache (~/.cache/erebus/build/{hash}.zst): the runtime layer is looked up by its tar hash. If an identical blob already exists, it’s reused without recompression — this is what makes rebuilds (and builds of apps sharing the same runtime) near-instant.

Final assembly, then chmod +x:

[ ELF stub ][ runtime layer ][ app layer ][ JSON metadata ][ sig? ][ footer ]
^0          ^payload_offset                              ^meta_offset      ^EOF-92

See .erebus Format for the layer table details.

Typical output

First build (cold build cache):

$ erebus build ./examples/bottle-web
[erebus] building 'bottle-web'
  runtime: python
  entrypoint: /usr/bin/python3.12 /app/app.py
  runtime layer: 5 shared libraries
    /lib/x86_64-linux-gnu/libc.so.6
    /lib/x86_64-linux-gnu/libexpat.so.1
    /lib/x86_64-linux-gnu/libm.so.6
    /lib/x86_64-linux-gnu/libz.so.1
    /lib64/ld-linux-x86-64.so.2
  runtime layer: embedded /usr/lib/python3.12
  app layer: site-packages from .../bottle-web/site-packages
  runtime layer: 54.0MB -> 11.9MB (zstd, cached)
  app layer: 0.2MB -> 0.0MB (zstd)
[erebus] wrote ./bottle-web.erebus (7.1MB) in 25.1s

Rebuild after code change (runtime layer reused):

  runtime layer: reused from build cache (no recompression) ✓
  app layer: 0.2MB -> 0.0MB (zstd)
[erebus] wrote ./bottle-web.erebus (7.1MB) in 1.2s

The resolved libraries (5) include the dynamic linker (ld-linux) and are deduplicated: if /lib64/ld-linux-x86-64.so.2 is a symlink to /lib/.../ld-linux-x86-64.so.2, only one path is kept (the symlink, so _copy_into_rootfs recreates the chain in the rootfs).

Dependency evolution

The ELF analyzer is built into detect.rs in pure Rust. It works on any machine with the Rust toolchain, without depending on a specific ldd. It handles ELF64, symlinks, DT_RUNPATH, LD_LIBRARY_PATH, and transitive resolution.

Cross-distro portability: isolation level 2 (user namespaces + pivot_root) ensures /lib64/ld-linux resolves inside the rootfs, not on the host. See Isolation.

pip install at build time

If the app has a requirements.txt with content (non-empty), the builder pip-installs dependencies into the rootfs Python site-packages layout.

[erebus] pip install: ./my_app/requirements.txt → …/site-packages

Separately, a vendored site-packages/ directory and/or the install tree from .venv / venv are merged into /app/site-packages, with PYTHONPATH=${ROOTFS}/app/site-packages recorded in metadata (the stub also injects that path when the directory exists). See Python.

Cache

Extracting the rootfs on every launch would be slow. erebus extracts once and reuses.

Layout

~/.cache/erebus/
  {sha256-of-payload}/
    rootfs/      ← extracted filesystem, ready to use
    .ready       ← marker: extraction is complete and valid

The cache key is the SHA-256 of the compressed payload. Two .erebus with identical content share the same cache; changing a single byte produces a new key. (.lock for concurrent access and last_used for LRU cleanup are planned — see Roadmap.)

Atomic extraction (anti-TOCTOU)

The danger: between checking that the cache exists and using it, an attacker could inject content (Time Of Check To Time Of Use attack). The defense is to never expose an intermediate state:

1. extract to  ~/.cache/erebus/.tmp-{pid}-{nanos}/   ← unique directory
2. write .ready once extraction is complete
3. rename() to ~/.cache/erebus/{sha256}/              ← atomic on Linux
4. if another instance won the race → discard our tmp

rename() is atomic on the same filesystem: either the final directory exists and is complete, or it doesn’t. No half-written state. This is why the tmp directory is created in the same parent directory as the target.

Cold start vs warm start

First executionSubsequent executions
Cachemissing → extractionpresent (.ready) → reused
Messagecold start: extracting...warm start: cache hit {hash}
Costzstd decompression + disk writenear-zero from erebus side

Today, warm “time to first HTTP byte” is dominated by the embedded runtime boot (Python interpreter startup, imports), not by erebus. The launcher’s own overhead is on the order of milliseconds. The < 100 ms end-to-end goal will require squashfs+mmap (no extraction) in Phase 3.

Two separate caches

Extraction cacheBuild cache
Path~/.cache/erebus/{sha256}/~/.cache/erebus/build/{hash}.zst
Sidetarget machine (at run)build machine (at build)
Contentextracted rootfs, ready to usecompressed reusable layers
Roleavoid re-extracting on every launchavoid recompressing on rebuild

The build cache is what makes a rebuild go from ~25 s to ~1 s (runtime layer reused). See .erebus Format.

Extraction cache key in v2

In v2, the key is not the payload hash but the SHA-256 of the concatenation of all layer hashes. As long as the layers are identical, the extracted entry is reused. (Per-layer reuse — extracting the runtime layer once and overlaying app layers via overlayfs — will come with isolation level 2; today an app layer change re-extracts everything on the target side, but the build-time gain is already achieved.)

Cleanup

erebus clean        # clear extracted cache entries, KEEP build cache
erebus clean --all  # wipe all ~/.cache/erebus (build cache included)

Isolation

erebus offers three isolation levels. Levels 0 and 2 are available; level 1 is skipped because level 2 makes it redundant.

Level 0 — LD_LIBRARY_PATH (available)

Extract the rootfs and launch the app with the right environment variables to find its libs:

LD_LIBRARY_PATH = rootfs/lib:rootfs/lib64:rootfs/usr/lib:...
  • ✅ Simple, portable, no privileges required.
  • ✅ Sufficient when the target machine is compatible (same glibc family).
  • ⚠️ The app sees the entire host filesystem (no isolation).
  • ⚠️ The ELF interpreter path (/lib64/ld-linux...) still resolves on the host → cross-distro portability not guaranteed.

A Linux kernel feature that allows chroot-like isolation without root privileges. This is what Docker uses in rootless mode.

Mechanism (implemented in stub/src/main.rs):

  1. unshare(CLONE_NEWUSER | CLONE_NEWNS) — new user + mount namespaces
  2. UID/GID mapping: write to /proc/self/uid_map, setgroups, gid_map
  3. Bind-mount the rootfs onto itself (MS_BIND | MS_REC, required by pivot_root)
  4. pivot_root(rootfs, rootfs/.old_root) + umount2("/.old_root", MNT_DETACH)
  5. std::env::set_current_dir("/") — CWD in the new root
  6. Install seccomp-bpf denylist (blocks ~14 dangerous syscalls)
  7. execvp(entrypoint) — the app sees only its rootfs

Usage:

erebus build my-app/ -o my-app.erebus --isolation 2

Implementation details:

  • The builder preserves symbolic links (e.g. /lib64/ld-linux-x86-64.so.2) in the rootfs so pivot_root resolves them correctly.
  • The ELF analyzer deduplicates .so files that point to the same real file via different symlinks, avoiding duplicates in the rootfs.
  • /etc/hosts is created in the rootfs (127.0.0.1 localhost) to prevent the DNS PTR lookup hang that blocked app startup.
  • The seccomp filter is a conservative denylist — only syscalls with no legitimate use in web/server apps are blocked (ptrace, mount, reboot, kexec, module loading, etc.). All networking, file I/O, memory, and process syscalls pass through.
  • If seccomp is unavailable (kernel without CONFIG_SECCOMP), the launcher prints a warning and continues without the filter.

Level 1 — chroot (skipped)

Make the app think the extracted rootfs is the system root. It only sees its own libs. Limitation: chroot() traditionally requires root, which we don’t want to ask for → skip directly to level 2.

Design decision

Isolation is a feature, not the core product. erebus’s value proposition is “one file, it runs”. We start at level 0 (which proves the full pipeline) and add isolation without changing the .erebus format: only the isolation metadata field and the launcher’s behavior change.

erebus build

Package an app directory into a single self-extracting .erebus executable.

erebus build [OPTIONS] [APP]

APP defaults to .. The output is app.erebus unless -o is given (a trailing slash appends app.erebus to the directory).

Runtime selection

The runtime is auto-detected from the app directory (Python, Binary). Override the detected interpreter with --embed-interpreter (for example python3 for Python, or the path to a native binary).

Common options

FlagDescription
-o, --output <PATH>Output file (default app.erebus)
-k, --key <PATH>Ed25519 signing key (32 raw bytes)
--isolation <MODE>Isolation level: none (0), sandbox (1), hard (2) (default sandbox)
--seccompInstall a seccomp BPF denylist at runtime
--landlockEnable Landlock LSM filesystem sandbox
--encryptEncrypt the payload (AES-256-GCM, key derived from signing seed)
--squashfsUse SquashFS instead of zstd+tar for the payload
--no-installSkip dependency installation
--env-file <PATH>Bake in a KEY=VALUE env file
--env KEY=VALUESet an env var (repeatable)
--define KEY=VALUEBuild-time define, injected as an env var (repeatable)
--version-info <STR>Version string recorded in metadata
--author <STR>Author string recorded in metadata
--description <STR>Description recorded in metadata
--license <STR>License string recorded in metadata
--include <PATH>Extra files/dirs to embed in the rootfs (repeatable)
--dry-runPrint the build plan without building
--jsonEmit a JSON build result on stdout
-v/--quietVerbose / quiet output

.env and secrets: an app directory containing a .env file is rejected unless you pass --include <app>/.env explicitly. The file is excluded from the payload by default because its secrets would be extractable from the redistributable binary by anyone who holds it. Prefer --env-file / --env to bake configuration into the binary metadata, and reserve --include .env for cases where bundling the file is the app’s intended deployment model.

SISR options (incremental self-updates)

FlagDescription
--enable-sisrEnable delta-indexing: content-chunk the payload, embed a SISR section and write <output>.manifest
--key <PATH>With --enable-sisr: signs the SISR manifest instead of the binary
--update-url <URL>Base URL of the update channel; embedded in the binary and used by --erebus-update
# Updatable binary: SISR section + signed manifest + embedded update channel
erebus build ./my_app -o my_app.erebus \
    --enable-sisr \
    --key ~/.erebus/keys/<fingerprint>.key \
    --update-url https://updates.example.com/my_app

This produces two artifacts:

  • my_app.erebus — the self-extracting binary (payload is content-addressed; unchanged chunks can be reused in place during an update);
  • my_app.erebus.manifest — the signed XBMR remote manifest that the launcher fetches and verifies before applying an update.

Publish both, plus the chunk files, to the update channel so target machines can run ./my_app.erebus --erebus-update (see User-updates).

Signing semantics

The Ed25519 key file is the same 32 raw bytes used by erebus sign. When --enable-sisr is given, --key signs the manifest (the SISR footer carries the signature; the launcher verifies it against your trusted keys). Because the binary signature block would be inserted after the metadata and truncate the SISR section, a single erebus build signs either the binary (no --enable-sisr) or the manifest (--enable-sisr), never both. The private key bytes are never printed; under Unix a key file that is not mode 0600 produces a warning.

Behavior

  1. Detects the runtime and installs dependencies (--no-install to skip).
  2. Copies the app into a rootfs, optionally embedding an interpreter.
  3. Creates a deterministic zstd+tar payload (or SquashFS).
  4. Builds metadata (env, entrypoint, hashes, update_url).
  5. Assembles the binary — with --enable-sisr the payload is content-chunked (64 KiB targets) and the signed manifest is emitted.

Dry run

--dry-run prints the full plan — runtime, isolation, compression, SISR, package managers, file count — without building anything:

erebus build ./my_app --enable-sisr --update-url https://… --dry-run

See also

Self-Incremental Sovereign Reconstruction (SISR)

Advanced, opt-in. SISR is an extension of erebus — it is not the core value proposition. A default erebus build produces a simple, static, self-contained binary with no reconstruction machinery. SISR is documented here so the two models are easy to tell apart.

The two erebus models

erebus ships in two shapes. They look alike (same ./app.erebus), but only one can update itself.

Classic .erebus (default)SISR .erebus (opt-in)
Builderebus build ./app -o app.erebuserebus build ./app -o app.erebus --self-update
Contentslauncher + payload (SquashFS)launcher + payload + embedded SISR engine
Updaterebuild + redistribute./app.erebus update — self-rebuilds from signed deltas
Toolchain on targetnonestill none (engine is embedded)
Behaviorstatic containerstatic container by default, updatable on demand
Trustsignature at launchsignature at launch + signed chain for every delta

The single most important property: both shapes run identically by default. SISR is dormant until the user asks for an update. Turning it on never changes what a normal launch does — the embedded engine is only invoked explicitly (invariant I-2 of the SISR spec).

Why SISR exists

A classic .erebus is immutable. Shipping a fix means:

[dev machine] erebus build  →  [v1.1 .erebus]  →  redistribute  →  [target]

For a handful of servers that’s acceptable. For fleets behind slow links, or for security patches that must land fast, redistributing the whole binary each time is wasteful. SISR moves the last step of that pipeline onto the target machine itself:

  • the binary fetches only the changed bytes (deltas / chunks),
  • it verifies them against a signed chain of trust,
  • it reassembles itself into the next version, atomically.

The reconstruction flow

[ Binary v1.0 ] ──( ./app.erebus update )──▶ [ Interrogate remote manifest ]
                                                │
                                                ▼
                                       [ Download deltas / chunks ]
                                                │
                                                ▼
                                   [ Validate Ed25519 signatures + hashes ]
                                                │
                                                ▼
[ Binary v1.1 ] ◀──( Rebuild & atomic swap )───┘

At every arrow, nothing is trusted until verified:

  1. Interrogate — the binary fetches the remote manifest for its channel.
  2. Download — it pulls only the blocks that changed since v1.0 (content-defined chunks, see the delta manifest spec).
  3. Validate — every block is checked against its SHA-256 content hash and the whole update against the signed manifest (Ed25519 chain of trust, anti-rollback index).
  4. Rebuild & swap — the new binary is assembled in a staging location, verified end-to-end, then committed atomically. An interruption leaves v1.0 intact and running.

Three responsibilities, one file

The flow above is driven by the embedded SISR engine, a dormant third responsibility inside the binary (next to the launcher and the payload):

+-----------------------------------------------------------------+
|                         APP.ERE (ELF)                          |
+-----------------------------------------------------------------+
|  1. Entrypoint Launcher  --> Bootstrap + runtime isolation       |
+-----------------------------------------------------------------+
|  2. Embedded SISR Engine --> Verify, fetch & assemble (dormant)  |
+-----------------------------------------------------------------+
|  3. Payload (SquashFS)   --> Application code & assets           |
+-----------------------------------------------------------------+
  • Launcher — unchanged. Reads the footer, verifies integrity, extracts, execs.
  • SISR Engine — only engaged by an explicit update trigger. Runs in-process, never shells out, never listens on a port.
  • Payload — the SquashFS image the engine reconstructs block by block.

What a developer needs to remember

  • SISR is opt-in at build time and explicit at run time.
  • The target machine never needs the erebus CLI, a compiler, or a system runtime — the engine is compiled statically into the binary.
  • Every update is signed, monotonic (anti-rollback), and atomic. An unsigned, replayed, or tampered update is rejected before any byte is written.
  • The cryptographic guarantees are identical to the ones erebus already provides at launch — SISR extends the same trust chain to updates, it does not create a weaker one.

Where to go next

Rollback & Resilience (SISR health gate)

Status: implemented (Mission 8). erebus-core sisr::health + sisr::resilience, wired into stub/src/main.rs, verified end-to-end by stub/tests/health_rollback.rs.

The SISR update engine guarantees the binary is never half-updated, but an atomically installed update can still be a bad update: it applies cleanly and then crashes at startup. Mission 8 closes that gap with a supervised startup check and an automatic, atomic rollback.

The guarantee

After any in-place SISR update, the launcher runs the new version under supervision. If it crashes or fails during the startup window, the previous version is restored atomically and executed instead. A version that fails the health check is quarantined and will not be re-installed.

This keeps the invariant “the running binary is always the last valid version” — “valid” now includes verified to start.

Flow

./app.erebus --erebus-update …   (or $EREBUS_SISR_MANIFEST=…)
   │
   1. SISR update requested?   ──no──►  normal run
   2. refuse_quarantined_target?  yes ►  error: update refused (target quarantined)
   3. apply_with_rollback_snapshot:
        a. copy ./app.erebus → ./app.erebus.bak        (same filesystem, atomic)
        b. engine apply (reuse/fetch/verify/swap)   (error ⇒ discard .bak, untouched)
        c. health_store.begin(<new-version>)        (state = Pending)
   4. health gate on the new version:
        Pending      ► supervised_launch:
             fork →
                 child: exec new app (or supervise_services)
             parent: wait up to EREBUS_HEALTH_TIMEOUT_MS
                 exited 0, or still running  ► confirm + discard .bak → supervise to exit
                 crashed / non-zero         ► record_failure
                                              attempts < max ► run app as-is
                                              attempts ≥ max ► quarantine + rollback
        Quarantined ► rollback (previous binary was still in place or is restored)
   5. normal extraction + exec

Snapshot lifetime

./app.erebus.bak lives only while the new version is unconfirmed:

  • update applies → snapshot taken before the swap, kept until the health gate decides;
  • healthy → snapshot discarded, .bak never shipped as part of the release;
  • crashing → snapshot restored (atomic rename back) and discarded.

The snapshot is same-filesystem by construction, so restore is a single atomic rename(2). Permissions are preserved so the restored binary keeps its executable bit.

Health store

Per-version records live in ~/.cache/erebus/health/<version>.json (XDG_CACHE_HOME-relative), versioned by the footer content hash SHA-256(payload ‖ metadata).

FieldMeaning
statepending → healthy / quarantined
attemptsconsecutive installs of this version that reached the health gate
last_checked / last_crashedRFC 3339 timestamps

Rules:

  • begin records an install as pending without resetting the failure counter — repeated installs of the same broken version accumulate.
  • confirm marks the version healthy (exit 0 or survived the startup window).
  • record_failure increments attempts; the version is quarantined once attempts >= EREBUS_HEALTH_MAX_ATTEMPTS.
  • a quarantined version is never re-armed by begin — it stays quarantined until manually cleared by deleting its record.

Policy knobs (environment)

VariableDefaultMeaning
EREBUS_HEALTH_TIMEOUT_MS10000startup window the app must survive
EREBUS_HEALTH_MAX_ATTEMPTS3installs before quarantine (0 = never quarantine)

Failure table

SituationOutcome
Update applies, new version exits 0confirmed healthy, .bak discarded, binary kept
Update applies, new version crashes / non-zerorecorded; .bak restored atomically; previous version runs
Same version fails repeatedlyquarantined; log printed
Re-install of a quarantined versionrefused before the snapshot — no swap
Backup missing during rollback (deleted externally)rollback error; new version stays

The quarantine pre-check runs before the engine does any I/O, so a refused install is cheap and leaves the running binary untouched.

SISR: Self-Incremental Sovereign Reconstruction

Status: Conceptual specification (Phase 1 — no code, no format change). Defines the trust model, invariants, and conceptual ABI for a runtime that can rebuild a .erebus by itself, from verified deltas, without any host dependency.

SISR (“Self-Incremental Sovereign Reconstruction”) turns a .erebus from a static container into an autonomous, modular system: the binary can receive a signed delta, verify it, and re-assemble a new version of itself locally — without the erebus CLI, without a compiler, and without any system runtime.

The purpose of this document is to fix the absolute invariants, the cryptographic trust model, and the conceptual boundaries before any code is written or any format field is added. It is the contract that implementation must satisfy, not the implementation.


1. Motivation

Today a .erebus is immutable: shipping a fix means rebuilding on a dev machine and redistributing the whole file. SISR addresses the deployment side of that loop — the self-healing runtime — so a target machine can update itself from signed deltas and recover from local corruption without the build toolchain ever being present.

SISR changes what a .erebus is:

Static .erebus (today)SISR .erebus (target)
Update pathRebuild + redistributeSelf-rebuild from signed delta
Requires toolchainOnly at build timeNever
Trust anchorSignature verified once at launchSignature chain re-verified at every reconstruction
Binary identityFixed by its bytesReconstructed from verified blocks

Without the invariants in this document, SISR degenerates into a remote-code execution hole: a malicious delta, a replayed old version, or a tampered local cache would each be fatal. The invariants below are therefore non-negotiable and absolute.

2. Invariants

I-1. Zero host dependency

A .erebus that reconstructs itself must never require, on the target machine:

  • the erebus CLI,
  • a compiler (Rust, C, Go),
  • any system runtime (node, python, docker),
  • any packaging tool (tar, squashfs-tools, zstd).

Consequence. All reconstruction machinery — delta decoding, Merkle/CDC verification, block assembly, block-to-filesystem materialization — must be embedded in the binary itself, compiled statically (musl, as the launcher already is) and usable through the same /proc/self/exe self-location pattern.

I-2. Application neutrality

Activating the SISR engine must never alter the default behavior of the hosted application. A user who executes ./app.erebus without any reconstruction trigger must get exactly today’s transparent behavior: launcher → verify → cache → exec the server/web app. SISR is a dormant capability: it only engages when explicitly invoked (new binary on disk, explicit update request), never implicitly.

I-3. Strict atomicity

Local reconstruction must be 100% atomic. An interruption at any point (power loss, SIGKILL, crash mid-assembly) must leave the previous binary perfectly intact and functional.

Consequence. The engine never writes in place. It assembles the candidate next version in a staging location, verifies it end-to-end, then atomically commits it (rename/swap semantics). A failed or interrupted reconstruction is invisible: the running and on-disk binaries keep being the last valid version.


3. Architecture: three responsibilities

A .erebus is conceptually split into three responsibilities that must remain cleanly separable:

+-----------------------------------------------------------------+
|                      APP.ERE (ELF/PE)                          |
+-----------------------------------------------------------------+
|  1. Entrypoint Launcher  --> Bootstrap + runtime isolation      |
+-----------------------------------------------------------------+
|  2. Embedded SISR Engine --> Verify, fetch & assemble            |
+-----------------------------------------------------------------+
|  3. Payload (SquashFS)   --> Application code & assets           |
+-----------------------------------------------------------------+
  1. Entrypoint Launcher — the existing stub. Reads footer, verifies integrity/signature, extracts, execs. Unchanged behavior for normal runs.
  2. Embedded SISR Engine — dormant code path that can rebuild the binary from signed blocks. Never runs during a default launch.
  3. Payload — the SquashFS application content. SISR reconstructs the payload (and, when needed, the launcher) as a new assembled .erebus.

The trust model requires that a .erebus may only apply a delta or a manifest signed by the same Ed25519 public key that signed the original binary — or by a valid delegated key present in the original header (see Trust policy).


4. Conceptual ABI: Launcher ⟷ SISR Engine

The boundary between the Launcher and the SISR Engine is the only interface the engine is allowed to use. It is kept minimal and fully in-process (no IPC, no network server, no subprocess):

┌────────────────────── Launcher (existing) ──────────────────────┐
│  read footer · verify · extract · exec                          │
├──────────────────────────────────────────────────────────────────┤
│                    SISR Engine (new, dormant)                   │
│                                                                 │
│  self-locate ( /proc/self/exe )                                 │
│  read manifest header                                            │
│  verify trust chain (Ed25519)                                   │
│  verify monotonic anti-rollback index                           │
│  fetch+verify blocks (CDC/Merkle)                                │
│  assemble candidate → verify → atomic commit                    │
└──────────────────────────────────────────────────────────────────┘

The engine never shells out and never calls the network at the OS level it controls end-to-end; fetching uses the same primitives the app already needs (plain HTTPS), but always verify-before-assemble.

5. Trust model

5.1 Chain of trust

original .erebus  ── Ed25519 ──►  owner signing key  ── delegate ──►  update key
  (trust anchor)                                                  (signs deltas)
  • The trust anchor is the Ed25519 public key recorded when the binary was first produced and signed. It is carried inside the signed region of the original file, so it cannot be tampered with without breaking the original signature.
  • A delta or manifest is accepted only if it is signed by the anchor key itself, or by a delegated key whose delegation record is signed by the anchor (or transitively by an already-validated delegated key).
  • Non-repudiation. Every accepted reconstruction is backed by a verified Ed25519 signature. The engine keeps a signed log of what it applied and when (created timestamp from the signed manifest), so an update cannot later be denied by its originator.

5.2 Anti-rollback

  • Each signed manifest carries a monotonic version index.
  • The engine stores the highest applied index in the local header/tracking state (itself integrity-protected).
  • A manifest whose index is lower or equal to the stored one is rejected. This blocks replay attacks that try to force the binary back to an old, vulnerable version.
  • The monotonic index is part of the signed region, so it cannot be lowered by editing the file.

5.3 Engine isolation

  • The SISR engine runs before the application payload is ever exposed to untrusted code, or in a dedicated context, so a compromised app cannot silently re-run or alter a reconstruction.
  • The engine must not be reachable as an attack surface by the running application: no open network port, no world-writable IPC path, no environment-triggered execution that bypasses signature verification.

5.4 TrustPolicy structure

TrustPolicy {
  anchor_public_key : Ed25519PublicKey   // trust anchor, from original
  allowed_delegates : [DelegatedKey]     // valid delegation records
  min_format_version: u8                 // never downgrade format
  allow_unsigned    : false              // SISR deltas are ALWAYS signed
}

6. Structures (conceptual)

The following structures define the data model of SISR. They are a contract for future implementation, not a format change to the current .erebus footer (see Compatibility).

6.1 ManifestHeader

ManifestHeader {
  magic           : "SISR\x01"            // SISR manifest marker
  version         : u8                    // manifest schema version
  signed_body     : SignedBody            // everything below is signed
}

SignedBody {
  monotonic_index : u64                   // anti-rollback counter
  base_sha256     : [u8; 32]              // hash of the binary this applies to
  target_sha256   : [u8; 32]              // hash of the binary it produces
  created         : DateTime<Utc>         // non-repudiation timestamp
  block_count     : u64                   // number of blocks in this delta
  blocks          : [BlockRef]            // ordered block references
  signature       : Ed25519Signature      // over the whole SignedBody
}

6.2 BlockDigest

A single content-addressed unit of the delta:

BlockDigest {
  block_id        : u64                   // ordinal in the target binary
  source_range    : Range<u64>            // byte range in the target layout
  content_sha256  : [u8; 32]              // hash of the decoded block bytes
  encoding        : "identity" | "zstd"   // how the block is stored in transit
  cdc_boundary    : bool                  // true if this is a CDC split point
}

Blocks are addressed by their content hash (Content-Defined Chunking), which makes the local cache content-addressed and safe: a block is reused from cache only if its content_sha256 matches what the manifest expects.

6.3 Block cache

The engine maintains a content-addressed local cache:

~/.cache/erebus/self/{hash}/blocks/<content_sha256>   ← immutable by key
~/.cache/erebus/self/{hash}/applied.index             ← monotonic counter
~/.cache/erebus/self/{hash}/reconstruct/<version>     ← staged candidates

A cache block is keyed by its content hash, so injecting a malicious block simply produces a different key and is rejected before assembly. The cache is advisory — never authoritative — and is always re-validated against the signed manifest.

7. The SisrRebuilder contract

The conceptual interface that the SISR engine implements — the contract that any implementation must satisfy:

trait SisrRebuilder {
    fn trust_policy()          -> TrustPolicy;          // static anchor
    fn verify_manifest(m)      -> Result<(), TrustError>; // sig + rollback
    fn fetch_and_verify(m)     -> Result<Vec<Block>, BlockError>; // CDC/Merkle
    fn assemble(m, blocks)     -> Result<Assembly, AssembleError>; // pure
    fn stage(candidate)        -> Result<(), StagingError>; // isolated
    fn commit(candidate)       -> Result<(), CommitError>;  // ATOMIC swap
}

Key properties of the contract:

  • verify_manifest first: any failure anywhere in the chain stops the reconstruction immediately. No partial assembly is ever observed.
  • assemble is pure: it produces the candidate bytes without touching the live binary. The only side-effecting step is commit, which is atomic.
  • No external dependency: all steps are self-contained in the binary (musl-static), matching invariant I-1.

8. Compatibility

  • No impact on existing .erebus executables (Phase 1). Old binaries remain plain static SquashFS containers; SISR is a dormant capability that does not run on default launch (invariant I-2).
  • No existing footer field, layer format, or magic constant is modified. The structures in §6 are conceptual contracts for a future format evolution; they become real only when the format spec changes explicitly, with version constants updated in lockstep (per project rule).

9. Security

9.1 Anticipated attacks

AttackScenarioPrimary defense
ReplayForce the binary to re-assemble onto an old, vulnerable versionMonotonic signed anti-rollback index (§5.2)
Malicious block injectionInject a malicious block into the local block cacheContent-addressed cache: wrong hash ⇒ wrong key ⇒ rejected (§6.2)
Manifest spoofingPresent a forged/edited manifestEd25519 chain of trust + non-repudiation (§5.1)
TOCTOU on cacheSwap cache content between check and useCache is re-validated against the signed manifest at assembly; content-addressing makes swaps detectable
Downgrade formatTry to reconstruct an older, weaker formatmin_format_version in TrustPolicy
Unsigned deltaInject an unsigned or partially signed deltaallow_unsigned = false; signature required on the whole SignedBody

9.2 Non-negotiable verification order

1. self-locate ( /proc/self/exe )
2. read manifest header, validate magic
3. ── VERIFY SIGNATURE over SignedBody ──   ← nothing happens before this
4. verify monotonic index > applied index   ← anti-rollback
5. fetch blocks + verify each content_sha256 (CDC/Merkle)
6. ── ASSEMBLE CANDIDATE (pure) ──          ← no live mutation
7. verify candidate hash == target_sha256
8. ── ATOMIC COMMIT ──                      ← rename/swap, interrupt-safe

Any failure at any step aborts the entire chain — the previous binary is untouched and still the running one.

10. Testing (conceptual)

These are the acceptance properties, not yet concrete tests:

  • Failure-propagation matrix: for every crypto failure (bad signature, replayed index, block hash mismatch, downgraded format, unsigned delta), assert the chain stops immediately and the on-disk binary is byte-identical to the previous version.
  • Atomicity tests: kill the engine at every assembly/commit step (SIGKILL, power-loss simulation); the previous binary must remain functional.
  • Responsibility-separation tests: default launch (no update trigger) must behave exactly like today’s static container — no SISR code path executes.
  • Anti-rollback tests: a signed manifest with a stale index is always rejected, even with a valid signature.
  • Cache-injection tests: tampering with any block in the local cache is detected and the reconstruction aborts.

11. Performance

No CPU/memory/disk impact at this stage — this is a conceptual definition. The engine is dormant during normal launches (invariant I-2); the performance budget for reconstruction is not specified here and will be defined when the implementation design is produced.


References

Builder Pipeline

Status: implemented (erebus-core assembly + sisr_stage). Describes how a .erebus is assembled, how the optional SISR stage fits into the packager, and the invariants that keep the two outputs consistent.

The packager lives in erebus-core/src/assembly.rs. Its entry point is assemble_erebus; the SISR-enabled variant is assemble_erebus_with_sisr. Both return the total file size and set executable permissions on Unix.

Classic pipeline

stub_bytes ─┐
payload    ─┼─► [stub][payload][metadata][footer] ──► app.erebus
meta_bytes ─┘      │                    │
              payload_sha256 = SHA-256(payload ‖ meta_bytes)
              footer         = format version, offsets, hash, magic

No key material is involved; flags = 0. This is the baseline that every legacy decoder understands.

SISR pipeline

assemble_erebus_with_sisr takes a [SisrBuildConfig] (enabled, chunk_target_size, optional signing_key). With enabled false the bytes written are strictly identical to the classic path — this is enforced by a test. With enabled true the layout becomes:

[stub][payload][metadata][DeltaManifest][SisrFooterExt][footer]

Steps, all in memory:

  1. Chunk the payload with FastCDC at chunk_target_size (sisr_stage::chunk_payload), hashing each chunk with SHA-256.
  2. Serialize the DeltaManifest (magic XBMD, chunk table).
  3. Merkle root over the chunk hashes (sisr_stage::merkle_root).
  4. Sign merkle_root ‖ manifest_bytes with the optional Ed25519 key (sisr_stage::sign); all-zeros signature when unsigned.
  5. Inject the manifest and the fixed 110-byte SisrFooterExt before the standard footer; set FLAG_SISR in the footer flags byte.
  6. Write the remote manifest to <name>.erebus.manifest (RemoteManifest::to_bytes): XBMR magic + merkle_root + signature + embedded DeltaManifest, self-contained and verifiable offline.

The footer’s absolute offsets (payload_offset, meta_offset) are unaffected by the insertion; only flags and the new blocks change.

Invariants

  • Disabled ⇒ identical output. assemble_erebus_with_sisr(…, disabled()) emits the exact bytes of assemble_erebus.
  • Reader/writer symmetry. The extension is located by the reader at file_len − footer_size − 110 (immediately before the footer) and the manifest via chunk_table_offset — the writer places the manifest between the metadata and the extension, satisfying the table_end ≤ ext_start bound.
  • No runtime coupling. The launcher is untouched; the classic and SISR files both boot through the stock footer.
  • Zeroize. The SigningKey (an ed25519-dalek type) drops its secret bytes when the config goes out of scope.

Performance

The SISR stage is single-pass and in-memory. The dominant cost is the SHA-256 per chunk (see the chunker notes); the FastCDC scan and the Merkle pairing add negligible time.

Measured (perf_sisr_on_100_mib, release build, i5-7300U @1.6 GHz, no SHA-NI, machine under load): 100 MiB payload, 64 KiB target chunks ⇒ 5.3 s (19 MiB/s), 812 chunks, 29 KiB manifest. The classic path already pays one full SHA-256 pass, so the stage roughly adds a second pass plus the scan; on CPUs with SHA-NI the compress-featured sha2 (runtime-dispatched) collapses this to well under the < 5 % build-overhead budget. On the current CPU the extra pass is the cost of content addressing — run the probe with cargo test -p erebus-core --release perf_sisr -- --ignored --nocapture.

Runtime Launcher: SISR self-update

Status: implemented (erebus-core sisr::engine + sisr::swap, wired into stub/src/main.rs). Describes how the launcher rebuilds the running binary from a signed delta manifest before extracting and executing it. Since mission 8 the launcher also supervises the first run of a freshly updated version and rolls back automatically if it crashes — see Rollback & Resilience.

The launcher flow in stub/src/main.rs is deliberately linear: locate self via /proc/self/exe, read the footer and metadata, apply a SISR update if one is requested, then extract the payload and exec. The SISR step sits between metadata parsing and the cache/extract phase, so the same run executes the new version.

Two trigger paths exist:

  • Local staging (mission 6): $EREBUS_SISR_MANIFEST points at a signed manifest whose chunks are staged in <manifest-dir>/chunks/. The launcher stays network-free; this path is unchanged.
  • Remote update (mission 7): ./app.erebus --erebus-update [URL] — the launcher intercepts the flag before the app sees it, downloads the manifest and the changed chunks over HTTPS, applies the delta, prints reuse/fetch statistics, and exits. The URL resolution order is --erebus-update <URL> argument > $EREBUS_UPDATE_URL > the update_url embedded at build time (erebus build --update-url). --erebus-version prints version info and exits.
./app.erebus  (EREBUS_SISR_MANIFEST=/updates/app.erebus.manifest   — local staging)
            (./app.erebus --erebus-update                        — remote update)
   │
   1. open /proc/self/exe → footer → metadata
   2. update requested?
        no  → skip to 6
        yes ↓
   3a. local: read + parse remote manifest (XBMR) from $EREBUS_SISR_MANIFEST
   3b. remote: GET {base}/manifest (HTTPS) and parse it
   4. verify Ed25519 signature against trusted keys (~/.erebus/trusted-keys/)
      verify Merkle root against the chunk table
   5. SisrEngine::apply_update(/proc/self/exe, manifest, fetcher)
        - reuse unchanged chunks from the current binary
        - fetch the rest — local: <manifest-dir>/chunks/<hex-hash>
                          remote: GET {base}/chunks/<hex-hash> (HTTPS)
        - SHA-256-verify every chunk before writing
        - write to .tmp, fsync, rename → atomic swap
       failure at any point ⇒ binary untouched, launcher exits with error
    6. re-open the *canonical real path* returned by the engine (not
       /proc/self/exe, which can still resolve to the pinned pre-update inode)
       and re-read footer + metadata — now the new version
    7. health gate (mission 8): snapshot `./app.erebus.bak` taken before the
       swap; the new version is supervised for its startup window
         - healthy  ⇒ confirmed, `.bak` discarded
         - crashing ⇒ recorded, `.bak` restored atomically, previous version runs
         - quarantined target ⇒ refused before the swap
    8. cache check → extract → exec as usual

The --erebus-update / --erebus-version paths are terminal: after the update the launcher prints statistics on stderr and exits without exec’ing the app, so those flags never reach the host application.

Trigger and chunk location

  • $EREBUS_SISR_MANIFEST — path to a signed remote manifest (RemoteManifest::from_bytes); when unset the launcher is stock. Chunk files are read from the chunks/ directory next to the manifest (<manifest-dir>/chunks/<64-hex-sha256>), served by DirectoryChunkFetcher.
  • --erebus-update [URL] — fetches <URL>/manifest and <URL>/chunks/<hex> over HTTPS via HttpChunkFetcher, resolving the base URL from the positional argument, then $EREBUS_UPDATE_URL, then the embedded update_url. The transport is never a trust anchor: the manifest is signature-verified and every chunk is SHA-256-verified by the engine before it is written.
  • When every chunk is already present in the running binary, no chunk file is touched and the server may not even be asked for chunks.

Security ordering (non-negotiable)

The manifest is authenticated before a single byte is written:

  1. RemoteManifest::verify_any(trusted_keys) — the Ed25519 signature over merkle_root ‖ manifest_bytes must verify against at least one key in the trusted-keys directory (same directory the embedded binary signature uses, see load_trusted_keys).
  2. RemoteManifest::verify_merkle() — the stated Merkle root must match the chunk table, so a signer error cannot smuggle a mismatched root.
  3. Per-chunk, inside the engine: every byte written — whether reused from the current binary or fetched — must SHA-256 to its ChunkEntry.hash. A fetched chunk of the wrong length or hash is rejected on the spot.
  4. At rest, on every cold start: the rebuilt binary embeds the manifest signature from step 1 in its SisrFooterExt; the launcher re-verifies it offline (signature → Merkle root → per-chunk payload hashes) before extracting. An updated binary therefore keeps its at-rest authenticity instead of degrading to integrity-only. Unsigned SISR sections are refused unless ERE_SISR_ALLOW_UNSIGNED=1 is set explicitly.

Only then does the engine assemble and atomically swap.

Atomicity guarantees

sisr::swap::AtomicWriter writes to .<tag>.tmp-<pid> in the same directory, fsyncs, then rename(2)s over the destination:

  • an error or an interruption before commit leaves the original binary byte-for-byte intact (the temp file is removed on drop);
  • the engine opens and hashes from the canonical path, and the swap is a single atomic rename — there is no “half-updated” state;
  • the source file’s mode is copied onto the temp file before the rename, so a replaced binary keeps its executable bit (File::create alone would yield 0o644);
  • after the swap the launcher re-opens the canonical real path returned by the engine — /proc/self/exe can keep resolving to the pinned pre-update inode — and continues with the new footer and payload.

Post-update health gate

Atomicity alone does not protect against a valid but broken update. Before the swap the launcher snapshots the running binary to ./app.erebus.bak (same filesystem → atomic restore); after the swap it supervises the new version for its startup window (EREBUS_HEALTH_TIMEOUT_MS, default 10 s):

  • the new version exits 0, or is still running when the window closes → health_store marks it healthy and the snapshot is discarded;
  • the new version crashes or exits non-zero → a failure is recorded; once attempts >= EREBUS_HEALTH_MAX_ATTEMPTS (default 3) the version is quarantined and the snapshot is restored, after which the previous version runs;
  • a quarantined target is refused at the top of the update path — before any snapshot or engine I/O — so a broken release cannot be re-installed in a loop.

Health records are JSON files in ~/.cache/erebus/health/, keyed by the target version’s content hash. See Rollback & Resilience for the full state machine.

What is copied, what is rebuilt

PieceHandling
stub (0..payload_offset)copied verbatim from the current binary
payload chunksreused when the current binary already has hash-verified bytes, else fetched + hash-verified
metadatacopied verbatim from the current binary (deltas never change metadata)
DeltaManifest + SisrFooterExtrebuilt from the fetched manifest and chunk table
footerrebuilt: flags = (old & ~FLAG_SIGNED) | FLAG_SISR, payload SHA-256 = SHA-256(payload ‖ meta), signature offset zeroed
SisrFooterExt.signaturecarries over the remote manifest’s Ed25519 signature (verified in step 1), so the rebuilt binary stays authentic at rest

A pre-SISR (legacy) binary is handled too: there is no embedded chunk index, so the engine falls back to fetching every chunk — correct, just not incremental.

Failure table

SituationOutcome
EREBUS_SISR_MANIFEST unreadable / bad magiclauncher exits, binary untouched
Manifest URL unreachable / non-2xxupdate refused, binary untouched
No update URL resolvable (--erebus-update alone)update refused with guidance
Signature fails (no trusted key verifies)update refused before any write
Merkle root mismatchupdate refused before any write
Fetched chunk wrong length or SHA-256engine errors, binary untouched
Embedded SISR section unsigned / untrusted / payload mismatch at cold startlauncher exits (override: ERE_SISR_ALLOW_UNSIGNED=1)
Chunk missing from chunks/ (local) or 404s (remote)engine errors, binary untouched
rename fails (read-only dir)engine errors, binary untouched
Power loss / SIGKILL mid-write.tmp may remain, binary untouched
Update applies, new version crashes at startup.bak restored atomically, previous version runs, failure recorded
Version fails EREBUS_HEALTH_MAX_ATTEMPTS timesquarantined; previous version runs
Re-install of a quarantined versionrefused before any snapshot or write

Internal Crates

These abstractions are the building blocks of the SISR engine. They are pure and in-memory: they never invoke an external tool (Command::new("squashfs") is forbidden by design), never touch the network, and are independent of the host filesystem layout. Their only contract is bytes in, bytes out, verified.

The internal modules of erebus-core that power SISR:

erebus_core
 ├── chunker      --> trait Chunker & FastCDC implementation
 ├── cas          --> trait ObjectStore (Content-Addressable Storage)
 ├── assembler    --> trait BinaryAssembler (binary stitching)
 ├── manifest     --> binary DeltaManifest (embedded SISR chunk index)
 ├── sisr_header  --> SisrFooterExt (fixed 110-byte access block)
 ├── sisr_stage   --> build stage: chunk payload, Merkle root, Ed25519 sign,
 │                    remote manifest
  └── sisr         --> runtime stage: sisr::engine (reconstruction) +
                       sisr::swap (atomic replacement) +
                       sisr::health (health gate state machine) +
                       sisr::resilience (rollback snapshot)

Each trait is deliberately small and single-purpose so the runtime reconstruction path stays auditable end to end.

chunker — Content-Defined Chunking

pub struct ChunkDescriptor {
    pub offset: usize,
    pub length: usize,
    pub hash: [u8; 32],   // SHA-256 of the chunk bytes
}

pub trait Chunker {
    fn chunk(&self, data: &[u8]) -> Vec<ChunkDescriptor>;
}

Splits a byte buffer at content-defined boundaries: the boundary position depends only on the bytes before it, not on any fixed offset. This is what makes incremental updates cheap — an edit only invalidates the chunks it touches, everything else stays byte-identical and is reused from cache.

Implementation: FastCDC. A rolling gear hash (fp = (fp << 1) + gear[b]) over a deterministic 256-entry gear table, with the classic two-mask normalization. Bounds default to min = avg/4, max = avg*4. Deterministic across runs and platforms: identical input always produces identical boundaries. See the delta manifest format for how chunks feed an update.

Throughput (measured, 256 MiB in memory, opt-level = 3). The boundary scan alone runs at ~390 MB/s on an i5-7300U (@1.6 GHz, under load) — above the 200 MB/s objective. End-to-end chunk() is bounded by SHA-256: on CPUs with SHA-NI the compress feature of sha2 (runtime-dispatched, no new dependency) reaches ~1-2 GB/s and keeps the pipeline above target; on CPUs without SHA-NI the soft implementation dominates (~50 MB/s on the test machine). The scan is the throughput-critical mechanism; the content-address hash is the price of integrity and should be deferred to the caller when a chunk already exists in the store.

cas — Content-Addressable Storage

pub trait ObjectStore {
    fn put(&mut self, hash: &[u8; 32], data: &[u8]) -> std::io::Result<()>;
    fn get(&self, hash: &[u8; 32]) -> std::io::Result<Option<Vec<u8>>>;
}

A byte store keyed by content rather than by name. The key is the SHA-256 of the value, so the same bytes always map to the same object — a malicious or bit-rotted chunk simply does not exist under the hash the manifest expects.

Verification on both write and read (anti-bitrot):

  • put fails unless SHA-256(data) == hash; the on-disk copy is re-read and re-verified before put returns.
  • get recomputes the hash of the stored bytes and fails on mismatch instead of returning corrupted data.

Two implementations ship: MemoryStore (tests/embedded use) and DiskObjectStore (one file per content hash under a root directory, with atomic rename-on-write).

assembler — Binary Stitching

pub trait BinaryAssembler {
    fn assemble(
        &self,
        base_exec: &[u8],
        payload_blocks: &[Vec<u8>],
        output: &mut dyn Write,
    ) -> std::io::Result<()>;
}

Reassembles a complete executable from a base binary and the reconstructed payload blocks, preserving the .erebus layout [stub][payload][metadata][footer]. The concrete ErebusStitcher reads the payload/metadata offsets from the footer and splices the new blocks into the payload region — the output is a valid .erebus indistinguishable from one produced by erebus build.

This trait exists so that format-specific splicing (ELF/PE/Mach-O, squashfs images) can evolve behind one stable contract.

sisr_stage — build-side SISR pipeline

Where the classic assembly module writes a plain .erebus, sisr_stage computes the SISR artifacts in memory:

pub struct SisrBuildConfig {
    pub enabled: bool,
    pub chunk_target_size: usize,
    pub signing_key: Option<ed25519_dalek::SigningKey>,
}

pub fn build_artifacts(payload: &[u8], config: &SisrBuildConfig)
    -> io::Result<SisrArtifacts>;

build_artifacts content-chunks the payload (FastCDC), serializes the DeltaManifest, computes the Merkle root over the chunk hashes, and signs merkle_root ‖ manifest_bytes with the optional Ed25519 key. The signature is all-zeros when no key is supplied (unsigned, integrity-only builds). The SigningKey is an ed25519-dalek type, so the secret is zeroized on drop.

The same module serializes and verifies the standalone remote manifest (.erebus.manifest): RemoteManifest::verify_signature and verify_merkle re-check both bindings without touching the binary — that is the ManifestVerifier role for the SISR path.

sisr — runtime reconstruction engine

The counterpart of sisr_stage, used by the launcher (stub): it rebuilds a .erebus on disk from the current binary plus a signed delta.

pub trait ChunkFetcher {
    fn fetch(&self, hash: &[u8; 32], length: usize) -> io::Result<Vec<u8>>;
    fn bytes_fetched(&self) -> u64;
}

pub struct SisrEngine;
impl SisrEngine {
    pub fn apply_update(&self, current_bin: &Path, manifest: &DeltaManifest,
                        fetcher: &dyn ChunkFetcher) -> io::Result<PathBuf>;
}

apply_update reads the footer and the embedded chunk index of the current binary, then tiles the manifest’s chunk table over the payload:

  • unchanged chunks are reused from the running file (offset looked up by content hash, bytes SHA-256-verified before reuse);
  • missing or corrupt chunks are fetched through the ChunkFetcher (DirectoryChunkFetcher reads <root>/<64-hex-sha256> files; a network fetcher can implement the same trait) and hash-verified;
  • the rebuilt file is written through sisr::swap::AtomicWriter (.tmp, fsync, atomic rename) so any interruption leaves the original intact.

Every chunk written — reused or fetched — must SHA-256 to its manifest entry; the engine never trusts a source. See runtime-launcher for the full flow and failure table.

Security properties

ConcernMechanism
Bit rot on diskSHA-256 re-verified on every ObjectStore::get
Malicious block injectionContent-addressing: tampered bytes ⇒ different hash ⇒ never found
Corrupt base during spliceFooter magic + offset bounds validated before writing
Replay / forgeryEd25519 signature over merkle_root ‖ manifest (embedded + remote)

Delta Manifest Format

Status: conceptual spec. No format has been frozen yet. This document defines the structure that a SISR update is described by, and how it applies to SquashFS images. It is the contract for a future format evolution, not a change to the current .erebus format.

An incremental update is driven by a manifest — a small, signed document that answers three questions:

  1. What is this update, and for which binary?
  2. What changed, and where (the delta / block list)?
  3. How do I verify each part (hashes, signatures, rollback guard)?

Everything else is derived from the answer.

1. Data model

1.1 Manifest header

ManifestHeader {
  magic            : "SISR\x01"
  version          : u8
  signed_body      : SignedBody
}

version is the manifest schema version. The launcher rejects manifests with a schema it does not understand (same rule as .erebus format versions).

1.2 Signed body — the trust boundary

The whole update decision lives in the signed body. Nothing outside it is trusted:

SignedBody {
  monotonic_index  : u64                // anti-rollback counter
  base_sha256      : [u8; 32]           // hash of the binary being updated
  target_sha256    : [u8; 32]           // hash of the binary being produced
  created          : DateTime<Utc>      // non-repudiation timestamp
  base_version     : string             // e.g. "1.0.0"
  target_version   : string             // e.g. "1.1.0"
  blocks           : [BlockEntry]       // ordered block references
  signature        : Ed25519Signature   // over the entire SignedBody
}

Guarantees:

  • Integrity + non-repudiation. The Ed25519 signature covers every byte of the decision (index, hashes, versions, block list). Nothing can be edited without invalidating it.
  • Anti-rollback. monotonic_index is part of the signed body, so a stale (replayed) update cannot be relabeled as fresh. The engine rejects any index ≤ the highest one it has applied.
  • Exact applicability. base_sha256 pins the update to a specific source binary. A manifest downloaded for v1.0 is refused by a v1.2 binary.
  • Exact outcome. target_sha256 lets the engine verify the rebuilt binary byte-for-byte before committing it.

1.3 Block entry — one content-addressed chunk

BlockEntry {
  block_id        : u64
  target_range    : Range<u64>          // byte range in the target layout
  content_sha256  : [u8; 32]            // hash of the decoded block bytes
  encoding        : "identity" | "zstd" // how the block is stored in transit
  source_uri      : string              // where to fetch the encoded block
}

Blocks are content-addressed (content_sha256). This is what makes the local cache safe: a block is reused from the cache only if its hash matches — a tampered block has a different hash, hence a different key, hence is never used.

2. SquashFS and content-defined chunking

The payload of a .erebus is a SquashFS image (or a zstd-tar layer). The engine updates it by reconstructing the image block by block. Two facts shape the design:

  1. SquashFS is a single, usually compressed, file. You cannot “patch a file in place” inside a compressed image — a one-byte change ripples through the compression window.
  2. Most of the image is unchanged between versions. Only the app code layer changes; the runtime layer is identical.

SISR therefore does not store whole-image diffs. It splits the target image into content-defined chunks (CDC) and lets the manifest reference only the ones that changed:

v1.0 image  chunk0  chunk1  chunk2  chunk3  chunk4        (5 chunks)
                 │        │                    │
                 └────────┴────────────────────┘  unchanged → reuse locally
                 ⬇
v1.1 image  chunk0  chunk1  chunk5  chunk3  chunk4        (chunk2 → chunk5)
                              │
                              └─ only this chunk is downloaded & verified

CDC boundaries are derived from the content itself (e.g. rolling hash hitting a target entropy), not from fixed offsets — so an edit in the middle of the app keeps most chunk boundaries stable and most chunks reusable.

Why per-block SHA-256 is not enough (and how it is completed)

Per-block SHA-256 verifies each chunk in isolation. SISR additionally binds the order and identity of the chunks with a Merkle-style commitment in the manifest:

target image bytes
   └── leaf: SHA-256(decoded chunk)          ← one per BlockEntry
          └── levels: SHA-256(left ‖ right)  ← hash pairs up the tree
                 └── root = target_sha256     ← signed in the manifest

Each leaf corresponds to a BlockEntry.content_sha256; internal nodes are hashes of child hashes; the root is the signed target_sha256. Reconstructing the image and recomputing the root proves, in one step, that every block is present, in the right order, and untouched — including blocks that were reused from the local cache rather than downloaded.

The non-negotiable verification order

1. self-locate ( /proc/self/exe )
2. read manifest header, validate magic + schema version
3. ── VERIFY Ed25519 SIGNATURE over SignedBody ──   ← nothing before this
4. verify monotonic_index > highest applied index   ← anti-rollback
5. verify base_sha256 == hash(current binary)       ← exact applicability
6. fetch missing blocks, verify each content_sha256
7. reuse cached blocks (verified by content hash)
8. ── ASSEMBLE candidate image (pure) ──
9. verify Merkle root == target_sha256
10. ── ATOMIC COMMIT ──                              ← interrupt-safe swap

Any failure at any step aborts the whole update. The previous binary is untouched.

3. Example manifest

{
  "magic": "SISR\x01",
  "version": 1,
  "signed_body": {
    "monotonic_index": 42,
    "base_sha256": "c0a1...",
    "target_sha256": "9e61...",
    "created": "2026-08-02T12:00:00Z",
    "base_version": "1.0.0",
    "target_version": "1.1.0",
    "blocks": [
      {"block_id": 0, "target_range": [0, 4194304], "content_sha256": "a1...",
       "encoding": "identity",  "source_uri": "https://updates.example.com/v1.1/b0"},
      {"block_id": 1, "target_range": [4194304, 8388608], "content_sha256": "b2...",
       "encoding": "identity",  "source_uri": "https://updates.example.com/v1.1/b1"},
      {"block_id": 2, "target_range": [8388608, 12582912], "content_sha256": "c3...",
       "encoding": "zstd",      "source_uri": "https://updates.example.com/v1.1/b2"},
      {"block_id": 3, "target_range": [12582912, 16777216], "content_sha256": "d4...",
       "encoding": "identity",  "source_uri": "https://updates.example.com/v1.1/b3"}
    ],
    "signature": "base64(...)"
  }
}

Note that the manifest references the target layout. Blocks that are identical to the current binary (same content hash) are reused locally — the engine only fetches the source_uri of blocks it does not already have in its content-addressed cache.

4. Local cache layout

The engine keeps a content-addressed cache, so reused blocks are validated by construction:

~/.cache/erebus/self/{hash}/
  blocks/<content_sha256>     ← immutable by key; tampering ⇒ different key
  applied.index               ← highest applied monotonic index (protected)
  reconstruct/<target_hash>/  ← staged candidate assembly (never live)
  • The cache is advisory, never authoritative — every block is re-verified against the signed manifest at assembly time.
  • applied.index is what makes replay impossible across runs: it records the highest monotonic index and is itself integrity-protected.

5. Relationship to the .erebus format

This manifest is an update artifact, not a new .erebus layout:

.erebus format (today)SISR manifest (future)
Roledescribes a binarydescribes how to build the next binary
Locationembedded at end of the fileexternal (fetched from a remote)
SignedEd25519 (optional today)Ed25519 (always)
Versioningformat version in footerschema version in header + monotonic index

The manifest’s target_sha256 is exactly the hash the rebuilt .erebus’s footer must contain. SISR reconstructs a valid, standard .erebus — the result is indistinguishable from one produced by erebus build.

This page describes the external update artifact (JSON). The .erebus file additionally embeds a compact binary chunk index — the same block list serialized as DeltaManifest — so the runtime can verify the embedded content before it is ever assembled. See .erebus Format v2 — SISR extension.

References

.erebus Format v2 — SISR extension

Status: implemented (erebus-core sisr_header + manifest modules). Specifies the .erebus layout enriched with the SISR header and the embedded delta manifest, and how it stays byte-for-byte backward compatible with existing decoders.

The stock .erebus layout [stub][payload][metadata][footer] is unchanged. SISR adds two blocks between the app metadata and the standard footer, and signals their presence with one spare bit of the footer flags byte.

Physical layout

+------------------------------------------------------------------+
| Executable Header & Embedded Runtime (ELF/PE)                    |
+------------------------------------------------------------------+
| Application Payload (SquashFS Compressed Data)                   |
+------------------------------------------------------------------+
| App metadata (JSON)                                              |
+------------------------------------------------------------------+
| Signature block (v3+, optional, unchanged)                       |
+------------------------------------------------------------------+
| SISR Section (DeltaManifest: chunk index)                       |
+------------------------------------------------------------------+
| SISR footer extension (110 bytes, fixed)                        |
+------------------------------------------------------------------+
| Standard erebus Footer (84 or 92 bytes, EREBUS_MAGIC at EOF)        |
+------------------------------------------------------------------+

Offsets within the standard footer (payload_offset, meta_offset, sig_offset) are absolute and therefore unaffected by the insertion.

Backward compatibility

  • EREBUS_MAGIC (0x5842494E + \x01) stays at the exact end of the file, and the standard footer is byte-for-byte identical with or without SISR.
  • Legacy decoders read the footer backwards from EOF; the SISR blocks sit before it and are never seen.
  • Presence is signaled by the FLAG_SISR bit (0x04) of the footer flags byte. Existing readers that only test FLAG_SIGNED / FLAG_ENCRYPTED are unaffected; a legacy file without the bit decodes transparently.
  • A new reader locates the extension at file_len - footer_size - 110, where footer_size is 84 (v2) or 92 (v3+), from Footer::footer_size().
offsetsizefieldmeaning
02sisr_versionextension schema version (1); 0 = absent
28chunk_table_offsetabsolute file offset of the DeltaManifest
104chunk_table_lenbyte length of the DeltaManifest
1432merkle_rootMerkle root over the payload chunk hashes
4664signatureEd25519 signature over merkle_root ‖ manifest

Serialized by SisrFooterExt::pack() / SisrFooterExt::parse(). The signature is computed over the concatenation of the 32-byte Merkle root and the serialized DeltaManifest, so the extension and the chunk table are bound together: any alteration of either invalidates the signature.

DeltaManifest (the SISR Section)

offsetsizefield
04magic XBMD
41version (1)
53reserved (zero)
84chunk_count
128payload_len
2036·nChunkEntry table

ChunkEntry = hash [u8; 32] + length u32, little-endian. Chunks are in payload order; each is addressed by content, so reuse and tamper detection follow from the hash. See the delta manifest format for how the chunk list drives an incremental update.

Remote manifest (<name>.erebus.manifest)

The builder writes a self-contained, signed copy of the manifest next to the binary:

| magic "XBMR" (4) | version (1) | reserved (3) | merkle_root (32) | signature (64) | DeltaManifest |

The signature is over merkle_root ‖ DeltaManifest (identical to the embedded header), so the remote file can be served over HTTPS / a package registry and verified offline without touching the .erebus. RemoteManifest::verify_signature and verify_merkle check both bindings.

Security

Strict bounds checking everywhere a hostile file could trigger OOB reads or over-allocation:

  • chunk_table_offset + chunk_table_len is checked with checked arithmetic and must end before the SISR extension (never in the footer or the extension itself).
  • The manifest buffer length must equal 20 + 36 × chunk_count exactly; count-derived sizes are computed with checked arithmetic before the chunk vector is allocated, so a forged chunk_count fails without allocating or over-reading.
  • Unknown schema versions are rejected (same rule as .erebus format versions).

Performance

Fixed overhead: 110 bytes for the extension. The manifest adds 20 + 36n bytes for n chunks. A 100-chunk app therefore adds ~3.7 KiB of SISR header metadata — under the 4 KiB budget. The extension carries the Merkle root and signature so the runtime can pre-check integrity without parsing the manifest.

Incremental Updates (SISR)

Advanced, opt-in. This guide covers the SISR extension: making a .erebus able to update itself from signed deltas. If you don’t need self-updates, ignore this page — a plain erebus build already gives you a static, self-contained binary.

Status: implemented — erebus build --enable-sisr produces an updatable binary and its signed manifest, and ./app.erebus --erebus-update applies the delta on the target (see User-updates for the end-to-end workflow).

When to use SISR

Use an updatable binary when you ship a fix or a feature to many targets and rebuilding + redistributing the whole file each time is the bottleneck:

  • fleets of servers behind slow links,
  • security patches that must land fast,
  • many identical deployments that can share deltas.

If you deploy to a handful of machines and can rebuild, SISR is overkill. The default static .erebus remains the right choice.

Overview of the workflow

DEV MACHINE                          TARGET MACHINE
                                     (no toolchain, no erebus CLI)

build + sign v1.0  ──────────────►   ./app.erebus --erebus-update
build + sign v1.1                     │  fetch manifest (HTTPS)
   │                                  │  verify signature + anti-rollback
   │                                  │  download changed chunks
   │  └─ publish manifest + chunks ─► │  verify hashes + Merkle root
   │        (remote / CDN / S3)       │  rebuild + atomic swap
   │                                  ▼
   │                              running v1.1

Three things are produced per release: the .erebus itself (for fresh installs), the manifest, and the chunks it references. Both the manifest and every chunk are verifiable without any tool — the verification runs inside the binary.

1. Build an updatable binary

# Static container — no SISR
erebus build ./my_app -o my_app.erebus

# Updatable binary — enables SISR and embeds the update channel
erebus build ./my_app -o my_app.erebus \
    --enable-sisr \
    --key $XDG_DATA_HOME/erebus/keys/<fingerprint>.key \
    --update-url https://updates.example.com/my_app

The two build the same payload. --enable-sisr additionally content-chunks the payload, embeds the initial trust policy (your signing key) and the update channel into the binary, and writes <output>.manifest — the signed delta manifest that gets published.

2. Sign the release

Signing a release uses the same Ed25519 keys as erebus’s existing signing support, but the object signed differs when SISR is on:

erebus keygen --key-dir $XDG_DATA_HOME/erebus/keys
erebus build ./my_app --enable-sisr --key $XDG_DATA_HOME/erebus/keys/<fingerprint>.key --update-url https://…

With --enable-sisr the --key argument signs the manifest, not the binary (the SISR section would be truncated by the binary signature block). Without --enable-sisr, erebus sign still signs the binary as before. The trust chain is fixed at build time: a .erebus only applies updates signed by the same key, or by a delegated key recorded in its header.

3. Publish the update

For each release, publish:

updates.example.com/
  my-app/
    manifest              ← signed XBMR manifest (the .erebus.manifest)
    chunks/<sha256>        ← content-addressed encoded chunks

The chunk names are content-addressed (see the delta manifest spec), so a chunk fetched by name can be verified against its hash before use. manifest is the file erebus build writes next to the binary (<output>.manifest). Publish it under the base URL you passed to --update-url, with the chunks/ directory beside it.

4. Update on the target

# Check for and apply an update (URL from --update-url, $EREBUS_UPDATE_URL,
# or an explicit argument — in that order)
./my_app.erebus --erebus-update

# Point at a different channel explicitly
./my_app.erebus --erebus-update https://updates.example.com/my_app

# Inspect versions without updating
./my_app.erebus --erebus-version

What happens under the hood — and what cannot be skipped:

  1. the binary fetches its manifest from the remote channel (HTTPS),
  2. it verifies the Ed25519 signature before doing anything else,
  3. it verifies base_sha256 matches the current binary (right version?),
  4. it verifies the Merkle root of the fetched chunk table,
  5. it downloads only the chunks it does not already have and verifies each (SHA-256 + length),
  6. it rebuilds the binary and commits atomically — an interruption leaves the previous binary intact.

5. Verification of a standalone .erebus

A .erebus produced by SISR is a standard, valid .erebus. Existing verification works unchanged:

erebus verify my_app.erebus --trusted-dir $XDG_DATA_HOME/erebus/trusted-keys

There is no separate “SISR format” to verify.

Failure behavior (what the user sees)

SituationOutcome
Manifest not signed / wrong keyupdate refused, binary untouched
Wrong base_sha256update refused (not applicable to this version)
Tampered chunk in cachechunk rejected by content hash
Merkle root mismatchcandidate discarded before commit
Power loss / SIGKILL mid-updateprevious binary still intact and running

In every case the running binary is the last valid version. There is no “half-updated” state.

Trade-offs

  • Payload grows — the SISR engine is embedded in the binary (a few hundred KB, statically linked).
  • A trust anchor is fixed at build time — to rotate the signing key you must rebuild and redistribute once.
  • Updates need a reachable manifest — self-update requires network access to the update channel.

None of this applies to a classic .erebus; it stays a zero-dependency static container.

References

Updating a deployed .erebus (SISR)

A .erebus built with --enable-sisr can update itself in place from a signed delta: it downloads only the chunks that changed, verifies every byte, and atomically swaps in the new binary. No toolchain, no erebus CLI, no extraction on the target machine — the update runs inside the binary itself.

DEV MACHINE                        TARGET MACHINE

erebus build ./app -o app.erebus \
  --enable-sisr --key …           ./app.erebus --erebus-update
  --update-url https://…            │  fetch manifest (HTTPS)
   │                                │  verify signature + Merkle root
   └─ publish to update server ───► │  download changed chunks
        app.erebus.manifest          │  SHA-256-verify every chunk
        chunks/<sha256>            │  atomic in-place swap
                                   ▼
                               app.erebus is now the new version

1. Build an updatable binary

erebus build ./my_app -o my_app.erebus \
    --enable-sisr \
    --key $XDG_DATA_HOME/erebus/keys/<fingerprint>.key \
    --update-url https://updates.example.com/my_app

Two artifacts are produced:

ArtifactPurpose
my_app.erebusSelf-extracting binary; self-updates against the channel
my_app.erebus.manifestSigned XBMR manifest, published to the server

2. Publish a release

Publish the manifest and the content-addressed chunks. Each chunk is served at <hash> = the 64-hex SHA-256 of its bytes, so a fetched chunk can be verified before use:

https://updates.example.com/my_app/
  manifest                     ← the .erebus.manifest (XBMR, signed)
  chunks/
    <64-hex-sha256>            ← one file per chunk
    <64-hex-sha256>
    …

Any HTTP server works (nginx, S3, a static CDN). HTTPS is recommended — the launcher uses TLS, though the trust anchor is the manifest signature and the content hashes, not the transport.

The publisher is responsible for keeping manifest and the chunks/ files in sync for the newest release.

3. Update on the target

# Uses the URL embedded at build time
./my_app.erebus --erebus-update

# Or point at a specific channel
./my_app.erebus --erebus-update https://updates.example.com/my_app

# Or override per-run via the environment
EREBUS_UPDATE_URL=https://updates.example.com/my_app ./my_app.erebus --erebus-update

Progress and statistics are printed on stderr (stdout is reserved for the app):

[erebus] update: fetching manifest from https://updates.example.com/my_app/manifest
[erebus] update: manifest verified (247 chunks)
[erebus]   fetched chunk 1/247 (65536 bytes)
[erebus]   fetched chunk 2/247 (65536 bytes)
…
[erebus] update applied: 203 chunks reused (12.7 MiB), 44 chunks fetched (2.8 MiB), total 247
[erebus] updated binary: /home/alice/app.erebus

The reused number is the bandwidth you saved: those chunks were already present in the running binary and were copied in place, never downloaded.

Other runtime flags

FlagEffect
--erebus-update [URL]Check the channel, verify, apply the delta, print stats, exit
--erebus-versionPrint the erebus stub and app versions, then exit

Both are reserved by erebus: they are intercepted by the launcher and are never forwarded to the application.

Security model

Nothing is trusted from the network:

  1. The manifest is verified with Ed25519 against the trusted keys in ~/.erebus/trusted-keys/ (same keys the binary signature uses) before a single byte is written.
  2. The Merkle root must match the manifest’s own chunk table.
  3. Every chunk — reused or downloaded — must SHA-256 to its manifest entry; wrong length or hash is rejected on the spot.
  4. The swap is atomic: any failure (bad signature, missing chunk, power loss) leaves the previous binary intact and runnable.
  5. The first run of the new version is supervised (mission 8): if it crashes during the startup window, the previous version is restored automatically and a failing release is quarantined so it is not re-installed.

Failure behavior

SituationOutcome
Manifest missing / bad magic / bad URLupdate refused, binary untouched
Signature fails (key not trusted)update refused before any write
Merkle root mismatchupdate refused before any write
Chunk wrong length or SHA-256engine errors, binary untouched
Chunk 404s on the serverengine errors, binary untouched
Interruption mid-updateprevious binary intact
New version crashes at startup.bak restored, previous version runs
Release fails EREBUS_HEALTH_MAX_ATTEMPTS timesquarantined, installs refused
Re-install of a quarantined releaserefused before any write

The running binary is always the last valid version — there is no “half-updated” state, and a broken release cannot wedge a target machine.

More

Migrating from v1 to v2 (SISR)

erebus v1 packages an app as [stub][payload][metadata][footer]. The v2 release introduces SISR (Self-Incremental Sovereign Reconstruction): the same layout with a delta manifest and a fixed access block injected before the footer, plus content-addressed delta self-updates.

This guide explains why your v1 binaries keep working untouched, and the two ways to migrate them.

Backward compatibility guarantee

A v1 .erebus is read, extracted, and executed by the v2 runtime exactly as before — no modification, no warning. SISR is additive: the launcher gates on a single flag in the footer, never on the file predating it.

                        [ .erebus file to load ]
                                   │
                       Contains SISR header?
                            /            \
                          YES              NO
                           │                │
                  [ v2 (SISR) ]      [ v1 (legacy) ]
                           │                │
            delta auto-update     standard extraction
            supported            only (no update)

The v2 runtime decides at load time:

  • FLAG_SISR set → the file embeds a delta manifest; --erebus-update and the EREBUS_SISR_MANIFEST path are available.
  • FLAG_SISR clear → classic extraction. Cache keying, integrity verification (SHA-256 over payload ‖ metadata), and signature checks are identical to v1.

Deployment scripts written against v1 remain 100 % valid against v2.

What an upgrade actually changes

erebus upgrade-binary <input_v1.erebus> <output_v2.erebus> performs an in-place format promotion:

before: [stub][payload][metadata][footer]
after:  [stub][payload][metadata][manifest][SisrFooterExt][footer]
  • The stub, payload, and metadata segments are copied byte-for-byte. The payload is chunked exactly as stored — never decompressed and recompressed.
  • The footer integrity hash SHA-256(payload ‖ metadata) and any checksum the payload embeds (e.g. SquashFS) are therefore preserved by construction.
  • payload_offset, meta_offset, payload_csize, meta_size are unchanged, so a legacy runtime that reads backwards from EOF keeps decoding the upgraded file.
  • A signed delta manifest (<output>.erebus.manifest) is written next to the binary, matching a fresh SISR build.

upgrade-binary refuses to touch a file that already has SISR, and refuses signed binaries (their signature block sits exactly where the manifest must be inserted — rebuild those with erebus build --enable-sisr).

Migration paths

erebus build ./app --enable-sisr \
  --update-url https://updates.example.com/app \
  --key ~/.erebus/keys/update.key

Best when you can regenerate the app image: you get the current launcher, a signed manifest, and an embedded update URL in one step.

Option B — promote an existing v1 binary

erebus upgrade-binary ./app-old.erebus ./app-new.erebus \
  --key ~/.erebus/keys/update.key

Use this to migrate already-deployed binaries without rebuilding the payload. The embedded launcher is the one from the original build; to gain the current launcher as well, rebuild (Option A). The promoted binary can be updated immediately:

./app-new.erebus --erebus-update https://updates.example.com/app

Deprecation governance

  • v1 (SISR-less) remains a supported, first-class input for the runtime and for upgrade-binary. No deprecation warnings are emitted.
  • v2 (SISR) is the recommended build output going forward.
  • The .erebus footer format (magic constants, field offsets) is frozen; new capabilities ship as flags and format-version bumps that are always backwards-readable, never repurposed fields.

Verifying a migration

erebus inspect ./app-new.erebus          # flags show SISR
erebus upgrade-binary ./app-new.erebus ./again.erebus
#   → error: input is already SISR-enabled  (expected)

The test suite enforces the invariant end-to-end: a legacy binary built with the current toolchain runs on the v2 launcher with no [erebus] output, and an upgraded binary applies a real delta through a mock update channel.

Changelog

All notable changes to erebus are documented here.

[Unreleased]

Added

  • examples/verify/body.sh (+ fixtures): offline smoke for verify-attest --request-body / --response-body (honest accept, forged refuse). Documents --expected-output-hash next to the offline pack.
  • attest-tool http --expected-output-hash: e2e that a wrong golden digest writes no receipt; verify-attest --expected-output-hash mismatch is exit 2.

Fixed

  • scripts/yc_demo.sh runs on stock macOS without GNU timeout (gtimeout or perl alarm fallback).

[0.6.3] — 2026-10-17

Fixed

  • Windows release build: LocalFree receives the SID string pointer as an untyped allocation, so erebus-stub compiles for x86_64-pc-windows-gnu.

Changed

  • Registry bearer comes from --token-file or ERE_TOKEN. A --token flag would show the secret in ps and in shell history.
  • The transparency log timestamps the checkpoint after the publish response, so a slow RFC 3161 server no longer holds POST /attestations.
  • An installed CLI with no local erebus-stub downloads the matching stub from the GitHub release and checks it against checksums.txt.
  • A release uploads one archive per built architecture plus checksums.txt.

Added (registry)

  • scripts/erebus_registry/: the registry’s proof core (Ed25519, Merkle, RFC 3161, transparency log) as a stdlib-only package; byte formats unchanged and pinned against the reference implementations in the battery.
  • Merkle roots and inclusion/consistency proofs are O(log n) over a cached append-only tree instead of O(n) list scans (200k leaves: ~0.3 ms vs ~0.5 s per query); leaf lookup is a hash map.

Added

  • Spec 2.1 action receipts: erebus serve --attest-key signs every tool call the engine returns (name and call id in clear, sha256 of the arguments) into execution.request.tool_calls, for chat completions (JSON, SSE deltas, legacy function_call) and the Responses API. A response that cannot be fully parsed is flagged tool_calls_incomplete instead of being reported as “no calls”. Receipts without tool calls stay byte-identical to 2.0.
  • erebus verify-attest <receipt> --response-body FILE: an auditor holding the plaintext response checks it against the signed output_hash and re-derives the signed tool-call list (exit 2 on any mismatch). Human output lists the signed tool calls.
  • Spec 2.1 tool linkage: the proxy also signs the request’s tool context (tools_offered, replayed tool_history, tool_results as call id + content digest). verify-attest --chain links every result to the call that requested it and reports injected results, rewritten history, changed results and calls to unoffered tools; --require-tool-linkage makes them fatal (exit 2). verify-attest <receipt> --request-body FILE re-derives the request side from the request body.
  • Spec 2.1 attested tool boundary: erebus attest-tool http --listen … --upstream … (plain HTTP tools, MCP Streamable HTTP with JSON or SSE answers) and erebus attest-tool stdio … -- <mcp server> sign one receipt per tool execution (tool_invocation: tool name, canonical arguments digest, result digests, error flag). verify-attest --chain AGENT --tool-trail [NAME=]DIR matches every issued call to an execution and reports results no tool produced (result_without_execution), results that differ from what the tool returned (result_not_from_tool) and tool runs no model call asked for (execution_without_call); --require-tool-execution makes them fatal (exit 2). Tool calls gain an optional arguments_jcs_hash (JCS of the parsed arguments) so the two sides match whatever the whitespace.
  • Tool executions can be bound to the engine’s call id: MCP params._meta["dev.erebus/call_id"] or the Erebus-Call-Id HTTP header is signed as tool_invocation.call_id. A bound execution matches only its call, so identical calls executed out of order no longer yield a spurious result_not_from_tool, and a tool that ran the call with other arguments is reported execution_altered (fatal with --require-tool-execution).
  • verify-attest --tool-trusted-dir DIR: tool trails are verified only against the tool operator’s keys, which must share none with the agent’s trusted keys, so a single stolen key can no longer forge both the agent and the tool trail. Reports tool_trust: separate|shared; shared trust is flagged in the human output.
  • Egress confinement: ERE_EGRESS=PORT[,PORT…]|none limits the payload’s outbound TCP to the given ports (macOS Seatbelt: localhost only; Linux 6.7+: Landlock TCP connect rules plus a seccomp filter refusing UDP, raw and packet sockets, TCP Fast Open, io_uring and foreign ABIs). Signed as execution.egress; an invalid policy or a kernel without Landlock ABI 4 refuses the launch. scripts/egress_e2e.sh (Linux) and scripts/macos_slice.sh check it on a real bundle against a control.
  • erebus evidence export <bundle> -o pack.zip and erebus verify-evidence pack.zip [--bundle] [--json]: a self-contained, offline-verifiable audit pack (receipts, inclusion proofs, checkpoints, RFC 3161 tokens, DSSE envelopes, signing keys). Export refuses an unverifiable trail; verify refuses any edited, missing, extra or path-traversing entry and a mismatched bundle (exit 2).
  • verify-evidence --operator-key <key> --log-key <hex> pins the keys an auditor obtained out of band; any other key in the pack is refused, and the report lists every key as pinned or NOT PINNED.
  • Evidence packs carry anchors/<receipt>.checkpoint, the exact bytes the RFC 3161 token covers, so openssl ts -verify -data checks the TSA signature without erebus.
  • Docs: guides/audit.md (auditor walkthrough); proof-first introduction, problem, positioning and comparison pages, in English; CLI help leads with the proof commands.

Fixed

  • Health-check server (stub): on macOS/BSD an accepted socket inherits O_NONBLOCK from the listener, so a probe that had not yet written its request got a reset instead of 200. Accepted sockets are now blocking with a 2 s read timeout.
  • SISR e2e mock server: same inheritance made e2e_sisr_main and upgrade_migration fail randomly on macOS (“Peer disconnected”).
  • erebus build detects Mach-O (thin and universal) agents as the binary runtime; it only knew ELF and PE, so native macOS apps were refused. Java class files, which share the fat magic, are not mistaken for them.
  • erebus build no longer stages a Linux glibc base (and warns about it) for non-ELF payloads.
  • verify-evidence --operator-key accepts the public key hex that erebus keygen prints, as well as the sha256: fingerprint.
  • attest --push also stores the inclusion proof beside the trail entry, so trails built with attest carry their proofs like serve --push ones.
  • Spec and docs: a batch run’s input_hash covers the launch vector (entrypoint + arguments), not stdin, and output_hash needs Linux isolation ≥ 2; the spec claimed stdin. Quickstart trusted-keys path fixed.

Fixed (macOS, ROADMAP P1-5)

  • --landlock bundles are now confined by Seatbelt (sandbox_init), and the launch is refused if the profile cannot be installed. Before, the profile was never applied. Payload reads are limited to the system trees and the rootfs; writes go to the rootfs and a private TMPDIR.
  • With ERE_ATTEST_KEY, macOS receipts now carry output_hash and exit_code (the Linux capture supervisor, shared).
  • A launch that failed inside the capture supervisor exited 0, so its receipt would have signed a clean run. It now exits 126.
  • scripts/macos_slice.sh: no KNOWN GAP left; the chain check no longer fails at random under pipefail.

Added (batch stdin)

  • Spec 2.1 execution.stdin_hash: with capture on, a non-terminal stdin is relayed by the supervisor and its sha256 signed, only when the whole input was delivered (Linux isolation ≥ 2 and macOS). Receipts without it keep the 2.0 bytes.

[0.6.1] — 2026-09-29 — internal pilot

Added

  • erebus serve --push --registry …: each inference receipt is published to the transparency log and its verified inclusion proof is stored beside the local trail entry; missing proofs are retried at the next start.
  • Reproducible T2 harness under scripts/pilot/: pinned Baguettotron Q8, persistent RFC 3161-anchored registry, enrollment, real probe and evidence-derived report.

Fixed

  • build --embed-model now stages the model before payload compression. Previously metadata advertised the ModelLayer, but the assembled payload did not contain the model bytes.
  • Attestation-trail enumeration ignores .proof.json sidecars, so publishing per-call proofs cannot corrupt sequence numbering or chain verification.
  • Stub: a fresh bundle could fail its very first launch with Invalid argument (os error 22). The cold-start rootfs hashing joins one worker per core, and the kernel wakes the joiner before the thread leaves the thread group, so the unshare(CLONE_NEWUSER) that follows was refused with EINVAL. enter_userns now waits for /proc/self/task to drain and retries the unshare; scripts/chaos_extreme.sh gained a 40-launch cold-start storm that reproduces the window under load.
  • erebus build right after a release install: the CLI now finds the erebus-stub shipped next to the erebus binary (host-target builds). It used to search only source-tree build dirs and PATH, and failed with “erebus-stub not found”.

[0.6.0] — 2026-09-28 — the proof chain

Added

  • Execution receipts spec 2.0 — DSSE envelope over the JCS (RFC 8785) canonical in-toto statement, predicate https://erebus.dev/attestation/execution/v2; signature mirrors the operator entry so 1.x consumers keep working. verify-attest accepts 1.x and 2.0. Spec: spec/attestation.md.
  • Machine identity — erebus enroll [--registry]: per-host Ed25519 key (0600), certificate signed by the registry key (POST /machines/enroll, GET /machines); every receipt produced on the host is co-signed; verify-attest --strict [--registry-key].
  • Sigstore interop — erebus keygen writes <fp>.pem, erebus attest --envelope FILE writes the bare envelope; cosign verify-blob-attestation verifies it (exercised in CI).
  • Transparency log — registry attestations/log.sqlite Merkle tree (RFC 6962/9162) with signed checkpoints, GET /log/{checkpoint,proof, consistency,key}, RFC 3161 anchoring (--tsa-url, --anchor-every); erebus attest --push stores <receipt>.proof.json; verify-attest --proof [--log-key] verifies it offline.
  • Warm-cache re-verification — rootfs.merkle manifest written at extraction; every warm start re-hashes the rootfs and re-extracts on drift; execution.rootfs_root is signed into receipts (ERE_ROOTFS_CHECK=off|strict).
  • Attesting reverse proxy — erebus serve --attest-key: one hash-chained receipt per inference call (execution.request).
  • Hash-chained local trail — <bundle>.attest.d/, execution.prev_attestation_hash, verify-attest --chain.
  • verify-attest hardening: display-copy consistency check, --bundle binding, fingerprint check.
  • Proof chain in CI — .github/workflows/proof.yml (README badge) runs scripts/test_transparency_log.py, the full scripts/yc_demo.sh (sandbox on, enroll, --strict, cosign, four chaos harnesses) and scripts/chaos_extreme.sh on every push and daily.

Fixed

  • erebus serve --llama-server PATH no longer requires a llama-server on PATH or a runtime pack: the given binary is used as the engine directly.

Changed

  • The in-toto statement now serializes predicateType (standard); the 1.x spelling predicate_type is still accepted and its signature bytes are reproduced for legacy verification.
  • Registry attestations moved from JSONL to SQLite (old journal imported once).

[1.0.0] — 2026-08-03 — SISR

First major release integrating SISR (Self-Incremental Sovereign Reconstruction) end to end.

Added

  • SISR delta self-updates — binaries embed a content-addressed chunk manifest; ./app --erebus-update <url> fetches only the changed chunks from the update channel and rebuilds the binary in place.
  • erebus upgrade-binary — promote a legacy (v1, SISR-less) .erebus to the v2 format by injecting the delta manifest and SISR header, preserving every payload byte and the integrity hash.
  • Post-update health gate + automatic rollback — a newly updated binary runs under supervision; a failed health check restores the previous version from a snapshot, and quarantined targets are refused.
  • Migration guide — see migration/v1-to-v2.md.

Compatibility

  • v1 (SISR-less) binaries run on the v2 runtime unchanged and without warnings: standard extraction only, zero startup penalty.
  • Deployment scripts based on v1 remain 100 % valid.
  • The .erebus footer format is frozen; new capabilities ship as flags and backwards-readable format-version bumps.

Testing

  • End-to-end suite against a mock HTTP update channel (cargo test -p erebus-stub --test e2e_sisr_main), network fault injection on the reconstruction engine, and property-based parser fuzzing via proptest.
  • Cross-version tests prove legacy binaries load on the v2 runtime and that upgraded binaries gain auto-update.

[0.3.0] — 2026-07-XX — Pre-SISR

Last release before the SISR integration. Classic [stub][payload][metadata] [footer] binaries only; no delta updates.

Roadmap

Source of truth: ROADMAP.md at the repository root: product state, audit findings, priorities and business model. This page is a summary and may lag behind it.

Priorities are strict

PriorityThemeState
P0Make the proof true: hardened verifier, hash-chained trail, attesting proxy (serve --attest-key), warm-cache re-verification, Merkle transparency log with RFC 3161 anchoring, machine identity, DSSE / in-toto with cosign interopcomplete (2026-09-17)
P1Make the claims continuously proven: demo and chaos batteries in CI (proof.yml), evidence export, one proof-first story, registry hygiene, macOS validated on Apple Siliconin progress
P2Differentiators: erebus policy (fail-closed rules enforced by the launcher), EU AI Act / SOC 2 evidence mapping, fleet dashboard, Windows sandboxafter P1

Out of scope: TEE remote attestation, zkML, hosting inference.

Frozen until P0/P1 are done

Distribution features that already exist and keep passing their tests but receive no new work: billing, mirror/replicate, Hugging Face import, layer swap, TUI dashboard and chat, protocol frontends, webhooks, SSO, Helm chart. A change to a frozen surface must name the P0/P1 item it unblocks.

Traction plan

  1. T1 — the whole proof chain re-proven in CI on every commit. Done.
  2. T2 — internal pilot: a real agent behind serve --attest-key --push, several enrolled machines, a public TSA-anchored registry, zero verification failures. Not done.
  3. T3 — a design partner in the target segments (defense / administration, industrial edge).
  4. T4 — a third party verifies a trail with only public keys and verify-evidence / cosign, in writing.

Pricing

Client and server are MIT, forever. Anyone can run their own registry and verify anything offline. What you pay for is the hosted, anchored history:

TierIncludes
Freepublic packages, local receipts, self-hosted registry
Pro · $29/moprivate packages, hosted transparency log, anchored checkpoints
Team · $99/mo+ RBAC, audit log, evidence packs, webhooks
Enterprise · $24k/yrair-gapped mirror + offline verifier, SSO, SLA, compliance reports
Enterprise+ · $50/node/yrper-machine identity at fleet scale, fleet dashboard, full history

Who it is for: teams that must show an auditor which version ran, on which machine, at what time — defense and public administration (air-gapped by regulation), industrial edge (kiosks, factories, vehicles), AI startups selling into regulated buyers. The buyer is usually the compliance or security function; the user is the platform team that ships the agent. Not for us: hosting inference, paywalling the client, healthcare (for now).

Why this is not a feature of your observability or agent platform: a tool the operator runs cannot, by construction, produce evidence the operator does not control. Traces in Datadog, LangSmith or an in-house store answer “what happened?” to the operator; a receipt anchored in a neutral, externally timestamped log answers it to the auditor. The launcher is simply the one point the operator cannot bypass to get that signature.

Why this is defensible while being open source: a transparency log is worth its accumulated, externally timestamped history. A fork can copy the code; it cannot re-create three years of anchored checkpoints, and a customer’s auditors already trust the log key their receipts point at. Same dynamic as Certificate Transparency and Sigstore’s Rekor.

Security

A tool that distributes and executes code needs security built into its architecture. This page documents each attack surface, the naive flaw, and the current defense.

Status: SHA-256 integrity, atomic extraction, Ed25519 signatures, user namespaces + pivot_root, seccomp-bpf denylist, and AES-256-GCM encryption are all implemented.

1. Authenticity — Ed25519 signatures

Attack: Anyone can produce a .erebus, and the user cannot verify its origin or whether it was modified.

Defense: Every .erebus can be signed. The launcher verifies the signature before extracting anything. Invalid or missing signatures result in execution refusal.

# Generate a keypair
$ erebus keygen --key-dir $XDG_DATA_HOME/erebus/keys
a1b2c3d4e5f6...

# Sign a .erebus (in-place, writes v3 footer)
$ erebus sign my_app.erebus --key $XDG_DATA_HOME/erebus/keys/a1b2c3d4e5f6.key
[erebus] signed my_app.erebus

# Verify before running
$ erebus verify my_app.erebus --trusted-dir $XDG_DATA_HOME/erebus/trusted-keys
[erebus] signature verified for /path/to/my_app.erebus

Why Ed25519 over RSA:

  • Short keys (32 bytes) and signatures (64 bytes) — minimal overhead in the footer.
  • Timing-attack resistant by design (constant-time scalar multiplication).
  • Standard in modern protocols (SSH, TLS 1.3, Signal, WireGuard).

Trust model: Trusted public keys live in $XDG_DATA_HOME/erebus/trusted-keys/. The launcher accepts the file if any trusted key verifies the signature. There is no central authority — trust is local and explicit.

# Trust a key
$ erebus trust $XDG_DATA_HOME/erebus/keys/a1b2c3d4e5f6.pub
[erebus] trusted key a1b2c3d4e5f6...

1b. Chain of Trust for Local Rebuilding

SISR (Self-Incremental Sovereign Reconstruction) lets a .erebus rebuild itself from signed deltas, with zero host dependency. This section fixes the cryptographic trust model for that path. The full conceptual specification is in SISR: Self-Incremental Sovereign Reconstruction.

Attack: A .erebus that can reconstruct itself becomes a remote-code execution surface. A malicious or replayed delta could replace the binary with an attacker-controlled (or simply old, vulnerable) version. On top of the original integrity concern (modification in transit), self-reconstruction adds three threats:

  1. Manifest spoofing — a forged or edited manifest passes off arbitrary content as an official update.
  2. Replay — a signed but old delta rolls the binary back to a vulnerable version.
  3. Cache poisoning — a malicious block injected into the local reconstruction cache replaces legitimate content.

Defense — three linked mechanisms:

  1. Signed chain of trust (non-repudiation). A .erebus only applies a delta or manifest signed by the same Ed25519 public key that signed the original binary, or by a delegated key whose delegation record is itself signed by the anchor (or transitively by a valid delegate). The anchor is carried inside the signed region of the original file, so it cannot be swapped without breaking the original signature.

  2. Monotonic anti-rollback index. Every signed manifest carries a monotonic version counter, stored as part of the signed body. The engine persists the highest applied index and rejects any manifest whose index is lower or equal. Replaying an old but validly-signed update is therefore impossible — and the counter cannot be lowered by editing the file without invalidating its signature.

  3. Verify-before-assemble (CDC/Merkle). Each downloaded block is verified against its content hash (Content-Defined Chunking) before assembly; blocks are addressed by content, so a malicious block produces a different key and is rejected. The engine assembles a candidate version, verifies the full result matches the signed target_sha256, and only then commits it atomically.

Non-negotiable verification order (every reconstruction):

1. self-locate ( /proc/self/exe )
2. read manifest header, validate magic
3. ── VERIFY SIGNATURE over the signed body ──   ← nothing runs before this
4. verify monotonic index > highest applied index  ← anti-rollback
5. fetch blocks + verify each block's content hash  ← CDC/Merkle
6. ── ASSEMBLE CANDIDATE (pure, no live mutation) ──
7. verify candidate hash == target_sha256
8. ── ATOMIC COMMIT ──                             ← interrupt-safe swap

Any failure anywhere aborts the entire chain: the previous binary remains byte-identical and functional. Unsigned deltas are never accepted — allow_unsigned = false is a hard invariant. See the SISR spec for the full contract.

2. Cache race condition — TOCTOU

Attack: Between checking that a cache entry exists and using it, an attacker could substitute the extracted rootfs with a malicious one.

Defense: Extraction writes to a unique temp directory, then atomically renames it to the final cache path. No intermediate state is ever visible to other processes.

1. extract to ~/.cache/erebus/.tmp-{pid}-{nanos}/   ← unique, private
2. write .ready marker
3. rename() to ~/.cache/erebus/{sha256}/              ← atomic on Linux
4. if another instance won the race → discard our tmp

rename() on the same filesystem is atomic: either the target exists and is complete, or it doesn’t. See Cache for details.

3. Integrity — SHA-256

Attack: The payload is corrupted in transit (bit flip, truncation, incomplete download).

Defense: The launcher recomputes SHA-256(payload ‖ metadata) and compares it to the footer’s payload_sha256 before extraction. On mismatch: exit(1), nothing written to disk.

# Corrupt one byte of a signed .erebus
$ dd if=/dev/urandom of=my_app.erebus bs=1 seek=688788 count=1
$ erebus verify my_app.erebus
[erebus] error: signature verification FAILED for /path/to/my_app.erebus

SHA-256 alone protects against corruption, not against an attacker who modifies the payload and recomputes the hash. This is why signatures exist — the hash is signed:

Ed25519_sign(SHA-256(payload ‖ metadata ‖ footer), private_key)

The footer is part of the digest because it decides whether the signature is consulted at all (format_version >= 3 + FLAG_SIGNED). A signature over payload ‖ metadata alone would let an attacker downgrade the file to v2, clear the flag, and recompute the SHA-256 — the signature would be silently skipped. Covering the footer makes any such tampering invalidate the signature; the launcher additionally rejects inconsistent states (sig block present without FLAG_SIGNED, or flag set without a block) and v2 files that still carry a leftover signature block from a downgrade.

Without the private key, forging a valid signature is computationally infeasible (2^128 operations for Ed25519).

4. LD_LIBRARY_PATH fallback (level 0)

Attack: At isolation level 0, the app sees the host filesystem via LD_LIBRARY_PATH. A malicious .so placed in a searched directory (/lib, /usr/lib) could be loaded instead of the real one.

Current status: Level 0 is the default because it works everywhere without privileges. Level 2 (user namespaces + pivot_root) eliminates this entirely — the app sees only its own rootfs.

Open question: If user namespaces are unavailable on the target, should we refuse to execute (security-first) or fall back to level 0 with a warning (UX-first)? The answer depends on the deployment context.

See Isolation for the full comparison.

5. Syscall filtering — seccomp-bpf

Attack: Even inside user namespaces + pivot_root, an attacker could use dangerous syscalls to escalate: load kernel modules (init_module), remount filesystems (mount), reboot the host (reboot), or trace other processes (ptrace).

Defense: A seccomp-bpf denylist is installed after pivot_root. It blocks ~14 syscalls that have no legitimate use in a packaged web/server app. All other syscalls (networking, file I/O, memory, process creation) are allowed.

Blocked: ptrace, mount, umount2, pivot_root, reboot, kexec_load,
         kexec_file_load, init_module, finit_module, delete_module,
         swapon, swapoff, sethostname, setdomainname, acct, nfsservctl

Why denylist, not allowlist: Python and Node.js use ~150+ distinct syscalls. Maintaining an allowlist that covers every runtime version and platform would break apps unpredictably. A denylist of the clearly dangerous syscalls is sufficient — namespace isolation handles the rest.

Graceful degradation: If seccomp is unavailable (kernel without CONFIG_SECCOMP, container that blocks prctl), the launcher prints a warning and continues without the filter. Isolation is defense-in-depth, not the primary security boundary.

macOS has no equivalent. There is no public syscall-filter API on macOS: Seatbelt (sandbox_init) filters operations (file, network, IPC), not syscalls, and Endpoint Security needs an Apple entitlement and root and authorizes events rather than filtering syscalls. On macOS the launcher therefore applies Seatbelt for the filesystem (--landlock) and for egress (ERE_EGRESS) only.

Egress confinement (ERE_EGRESS). ERE_EGRESS=PORT[,PORT…] (or none) limits the payload’s outbound TCP to those ports, typically the port of an erebus attest-tool http boundary so that tools are reachable only through it. macOS: Seatbelt, localhost:<port> only. Linux 6.7+: Landlock CONNECT_TCP port rules (any address) plus a seccomp filter refusing UDP, raw and packet sockets, TCP Fast Open (which connects from sendto and bypasses Landlock’s connect check) and io_uring. Unlike the filter above it never degrades: an invalid value or an older kernel refuses the launch. The enforced policy is signed in the receipt as execution.egress. Unix-domain sockets and processes the payload spawns over stdio are not confined.

6. Payload encryption — AES-256-GCM

Attack: A .erebus file is intercepted at rest (stolen laptop, shared storage, leaked artifact). Without encryption, anyone can extract the embedded application with tar after stripping the stub.

Defense: Optional AES-256-GCM encryption (v4 format, --encrypt flag). A fresh random 32-byte encryption key is generated at build time and stored (hex-encoded) in the binary’s metadata next to the ciphertext; the AES key is then derived from it via HKDF-SHA256. The encryption key is deliberately independent of the Ed25519 signing seed — the seed is never embedded in the binary, so a key leak does not compromise authenticity.

# Build with encryption
$ erebus build my_app/ --key $XDG_DATA_HOME/erebus/keys/a1b2c3d4.key --encrypt
[erebus] encrypted: 12.3MB -> 12.3MB (AES-256-GCM)
[erebus] wrote my_app.erebus (12.5MB, signed+encrypted)

Security model:

  • Encryption protects the payload at rest against casual extraction.
  • It does not protect against a determined attacker on a machine that must run the decrypted app — the launcher decrypts before exec, and the encryption key is embedded in the metadata for that exact purpose. This is the same fundamental limit as any DRM system.

Why this is still useful:

  • Prevents tar extraction of the embedded app
  • Protects against opportunistic theft (stolen build artifacts)
  • Adds a layer of defense-in-depth alongside signatures
  • The embedded key and ciphertext are both covered by the payload integrity hash and signature — tampering with either breaks the SHA-256/signature check before decryption runs

Key derivation:

AES_key = HKDF-SHA256(
    key  = random_encryption_key,      # 32 bytes, generated per build
    salt = random_salt,                # 32 bytes, generated per build
    info = "aes-256-gcm-key"           # fixed per algorithm
)

Both random_encryption_key and random_salt are stored in the binary metadata (meta.crypto) alongside the ciphertext. With SISR chunking, each chunk additionally gets an independent key via HKDF(key, salt, chunk_index) and a unique nonce derived from a per-build base nonce.

Verification order (non-negotiable):

1. open("/proc/self/exe")               ← self-locate (not argv[0])
2. read footer, validate magic           ← reject early if not .erebus
2. ── VERIFY Ed25519 SIGNATURE ──        ← nothing executes before this
2. verify payload SHA-256                ← corruption check (on ciphertext if encrypted)
3. ── DECRYPT PAYLOAD ──                 ← only after steps 3+4
4. atomic extraction (tmp → rename)      ← no TOCTOU window
5. user namespace + pivot_root           ← filesystem isolation (level 2)
6. seccomp filter                        ← syscall filtering (level 2)
7. exec entrypoint

Decryption happens after signature verification and SHA-256 integrity check. We decrypt what’s already proven authentic. The SHA-256 hash covers the ciphertext, not the plaintext — this ensures integrity verification and signature verification both operate on the same bytes.

Nothing is written to disk before integrity is verified. With signatures enabled, nothing executes before the signature is verified. With encryption, nothing is decrypted before both signature and integrity are verified.

7. Execution receipts — what ran, provably

Every execution (launcher exec, erebus serve --attest-key per inference call, erebus attest) emits a spec 2.0 receipt: a DSSE envelope over the JCS-canonical in-toto statement, signed by the operator key and — once the host has run erebus enroll — co-signed by a per-machine key certified by the registry. Receipts are hash-chained locally (<bundle>.attest.d/) and can be published to the registry’s Merkle transparency log (RFC 6962), whose signed checkpoints are timestamped at an RFC 3161 TSA.

Verifier guarantees (erebus verify-attest):

  • every printed value comes from the signed statement; the human-readable copies are re-derived and compared (TAMPERED: <block> mismatch otherwise);
  • the operator key is looked up by fingerprint, so a trusted machine key can never satisfy the operator check;
  • --strict demands the machine co-signature, a signed execution.machine_id matching the machine block, and a certificate from a trusted registry key;
  • --proof re-hashes the exact receipt bytes into the leaf, walks the inclusion path to the signed root and checks the TSA token — offline;
  • 1.x receipts (pretty-JSON signature) remain verifiable; --strict rejects them.

Full schema, canonical byte formats and threat notes: Execution attestation — spec 2.0.

Testing

erebus ships four layers of tests. They run on a stable toolchain only, and the full suite fits comfortably under 30 seconds in CI, with no root access.

Quick reference

# Formatting
cargo fmt --check

# Lint (per-crate, as CI does)
cargo clippy -p erebus-core --all-targets -- -D warnings
cargo clippy -p erebus-stub --all-targets -- -D warnings
cargo clippy -p erebus-cli --all-targets -- -D warnings

# Everything
cargo test --workspace

# Docs build
mdbook build docs/

Layer 1 — unit tests

#[cfg(test)] mod tests inside each module of erebus-core. Covers the chunker, Merkle roots, tar/zstd round-trips, footer codec, and parser edge cases.

Layer 2 — property-based tests (proptest)

#[cfg(test)] mod proptests in erebus-core/src/manifest.rs, erebus-core/src/sisr_header.rs, and erebus-core/src/format.rs. Each test runs 256 generated cases:

  • arbitrary bytes never panic any parser (DeltaManifest::parse, SisrFooterExt::parse, Footer::read_from, read_sisr);
  • pack/parse and serialize/parse round-trips are lossless;
  • truncated buffers are always rejected, never a panic.

Because proptest runs on stable, it is the primary fuzz surface in CI. libFuzzer targets live in fuzz/ for nightly toolchains.

cargo test -p erebus-core --lib

Layer 3 — SISR engine fault injection

erebus-core/src/sisr/network_test.rs drives the real engine through a ChunkFetcher wrapper that injects network faults:

  • latency (thread::sleep) — result unchanged;
  • abrupt connection drops (io::ErrorKind::ConnectionReset) — update fails, binary untouched;
  • corrupted packets — fails SHA-256 verification;
  • truncated packets — fails the length check;
  • slow throughput — still reconstructs correctly;
  • fetched-byte accounting.
cargo test -p erebus-core --lib sisr::network_test

Layer 4 — end-to-end (stub integration)

stub/tests/e2e_sisr/ launches the real erebus-stub binary (CARGO_BIN_EXE_erebus-stub) against a standard-library TCP mock HTTP server. No root, no network access: XDG_CACHE_HOME/XDG_DATA_HOME isolate the cache, EREBUS_TRUSTED_DIR pins trusted keys, and EREBUS_HEALTH_TIMEOUT_MS keeps the health gate fast.

cargo test -p erebus-stub --test e2e_sisr_main

update_basic.rs covers the happy path (v1→v2 swap, delta-bound downloads with ≤ 2% overhead on modified bytes, the new payload actually runs, and the mission-6 local staging path still applies).

update_failures.rs covers every refusal path — untrusted signature, corrupt manifest, missing chunk (404), truncated chunk, corrupted chunk bytes, and Merkle-root mismatch — and asserts the previous binary stays intact with no residual .bak.

The health-gate rollback E2E lives separately:

cargo test -p erebus-stub --test health_rollback

CI notes

  • Run the full suite with cargo test --workspace; it must finish in well under 30 seconds.
  • No test requires root, network access, or a running daemon.
  • fuzz/ is intentionally not a workspace member: libFuzzer needs nightly, and the stable-only policy forbids nightly in CI.

libFuzzer (nightly only)

fuzz/ contains a cargo-fuzz target covering every parser at once:

rustup toolchain install nightly
cargo +nightly install cargo-fuzz
cargo +nightly fuzz run sisr_manifest

The same inputs are exercised by the proptest layer on stable, so CI coverage does not regress on machines without a nightly toolchain.