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:
- The problem — the auditor’s question erebus answers.
- What erebus proves — and what it does not — the honest boundary.
- Auditing an erebus trail — for the person receiving the evidence.
- Execution attestation — spec 2.0 — byte formats.
What happens, and who does it
| Step | What happens | Who |
|---|---|---|
build / sign | app, runtime, libraries and weights in one sandboxed binary; Ed25519 signature over SHA-256(payload ‖ metadata ‖ footer), anti-downgrade | developer |
run / serve --attest-key | the 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 key | automatic, on every run |
--push | receipt published to the registry’s transparency log; inclusion proof, signed checkpoint and RFC 3161 token stored beside it | automatic, or attest --push |
verify-attest | one receipt or a whole trail: --chain, --proof, --strict — offline | anyone |
evidence export / verify-evidence | one zip with receipts, proofs, TSA tokens and keys; verified offline against pinned keys | operator → 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 serveexposes 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 runtalks OpenAI-compatible or Anthropic HTTP (--base-urlfor Ollama, Azure, llama-server, …), MCP tools in the loop (--mcp/--mcp-stdio), optional--stream(display unsigned; receipt hashes the assembled body), and--encapsulateso the receipt subject is a task.erebushash. 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.
| Tool | What it gives | What it does not give |
|---|---|---|
| Application logs | text lines written by the app | a 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 database | tamper evidence against the account owner; offline verification by a third party |
| Build provenance (Sigstore, SLSA, in-toto) | who built which artifact from which source | anything about runs: when, where, with what input |
| Container signing (OCI + cosign) | the image was signed before deployment | that this image is what actually ran on a given host at a given time |
| Tamper-evident log stores | append-only, verifiable storage | what is appended: it records what the application tells it |
| TEE remote attestation | the platform and code measured by hardware | per-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:
| # | Fact | Evidence in the signed statement |
|---|---|---|
| 1 | What ran — exactly this code and these weights, unmodified at launch | artifact.bundle_hash, artifact.payload_hash, execution.rootfs_root (Merkle root of the extracted rootfs, re-checked on every warm start) |
| 2 | Where — on a host whose key was enrolled and certified for a named owner | machine co-signature in the DSSE envelope + registry-signed certificate (erebus enroll, verify-attest --strict) |
| 3 | When — at a time nobody can backdate | leaf in a public Merkle transparency log, signed checkpoint, RFC 3161 timestamp from an independent TSA (verify-attest --proof) |
| 4 | With 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 |
| 5 | That the trail is intact — no receipt was deleted, reordered or edited afterwards | execution.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
| Claim | Why not | What 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 LLMs | Roadmap 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 policy | That 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 outputs | Receipts 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 compromised | The 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 valid | erebus checks the token structurally (imprint, policy, time) and stores it | openssl ts -verify against the TSA chain, documented in the spec |
| Compliance with a given regulation | Mapping receipts to EU AI Act record-keeping, SOC 2 or ISO 42001 is the compliance team’s work | erebus 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
- 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. - 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.
- 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
| Layer | What it proves | Signed by | Verified by |
|---|---|---|---|
| Statement (in-toto v1) | bundle/payload digests, provenance, execution facts | — (payload) | verify-attest |
| DSSE envelope | the statement was produced by the operator key | operator Ed25519 key | verify-attest, cosign, in-toto |
| Machine co-signature | the receipt was produced on an enrolled host | machine Ed25519 key (erebus enroll) | verify-attest --strict |
| Machine certificate | the machine key belongs to <owner> | registry key | verify-attest --strict --registry-key |
| Transparency-log proof | the receipt was published at position i of a signed tree | registry log key (+ RFC 3161 TSA) | verify-attest --proof |
| Local trail | receipts form an append-only hash chain | operator 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>"
}
}
}
envelopeandmachineare absent on spec 1.x receipts.machineis absent on 2.0 receipts produced by a host that never ranerebus enroll.signaturemirrors 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)
envelope.payloadType == application/vnd.in-toto+json.base64decode(envelope.payload) == JCS(statement)— otherwiseTAMPERED.- Pick the trusted key whose fingerprint equals
signature.key_fingerprint; the envelope must carry a valid signature by that key underkeyid == fingerprint. Looking the key up by fingerprint (instead of “any trusted key signed something”) keeps a trusted machine key from satisfying the operator check. signature.signature_hex == hex(base64decode(sig))of that entry.- Re-derive the statement from
artifact/provenance/executionwith the statement’s ownpredicate_type(v1 or v2) and compare (§4).
2.4 Machine verification (verify-attest --strict)
machineandenvelopepresent (1.x receipts fail--strict).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.machine.machine_id == "sha256:" + hex(sha256(machine.public_key_hex)).- The envelope carries a valid signature by the machine key.
machine.certificatepresent, for the same key, and its signature verifies under a trusted registry key (--registry-key HEXor a.pubfile 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 keypredicateType.
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.
| Platform | Mechanism | Enforced |
|---|---|---|
| macOS | Seatbelt network-outbound | TCP and UDP only to localhost:<port> (scope: loopback) |
| Linux ≥ 6.7 | Landlock CONNECT_TCP (ABI ≥ 4) + seccomp | TCP 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).
| Field | Value |
|---|---|
choice | choices[].index of a chat completion; 0 for the Responses API |
id | engine call id (call_…; Responses API call_id), links the call to the tool message that answers it; null for a legacy function_call |
name | function name, in clear: an auditor must read which action ran |
arguments_hash | sha256 of the exact arguments string (stream fragments concatenated); JCS bytes when an engine sends an object |
arguments_jcs_hash | optional: 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:
sha256(FILE) == execution.output_hash, elseTAMPERED: response body mismatch.- 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. OtherwiseTAMPERED: 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:
| Field | Source (chat completions / Responses API) |
|---|---|
tools_offered | tools[].function.name, legacy functions[].name / tools[].name of type: function; built-in tools without a name are skipped |
tool_history | messages[role=assistant].tool_calls and function_call / input[type=function_call], digested exactly like tool_calls |
tool_results | messages[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:
| Finding | Meaning |
|---|---|
result_without_call | a result was fed back for a call id the engine never issued in this trail — an injected result |
history_without_call | history replays a call id the engine never issued |
history_altered | history replays an issued id with another name or arguments_hash — a rewritten conversation |
result_rewritten | a second, different content_hash for an id issued once |
unoffered_tool | the 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. Eachtools/callrequest with its response (matched by JSON-RPCid) is one receipt, withinput_hash/output_hashthesha256of the two messages without their newline, methodSTDIOand pathtools/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:
| Field | Value |
|---|---|
protocol | mcp (JSON-RPC request) or http |
name | MCP params.name; absent for plain HTTP (bind it with --tool-trail NAME=DIR) |
arguments_jcs_hash | same canonical digest as §3.2: MCP params.arguments, or the HTTP request body (blank → {}) |
result_hashes | every 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_error | JSON-RPC error, MCP isError: true, HTTP status ≥ 400 or no response |
incomplete | the response was not observed (connection lost, compressed or oversized body) |
call_id | optional: 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:
- Bound: the execution whose
call_idequals the call’s id. The tool must have run exactly that call (same name, samearguments_jcs_hash), otherwise the finding isexecution_altered. A bound execution is never matched to any other call. - Unbound: otherwise, an execution without
call_idwith the samearguments_jcs_hashand 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”.
| Finding | Meaning |
|---|---|
result_without_execution | a result was fed back for an issued call that no attested tool executed |
result_not_from_tool | the fed-back content_hash is not one of the matched execution’s result_hashes |
execution_without_call | a tool execution no issued call accounts for (including one bound to an id the engine never issued), reported at its tool receipt |
execution_altered | the 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:
| Field | Meaning |
|---|---|
requested_at | ISO 8601 UTC when the stop was first observed |
reaped_at | ISO 8601 UTC when the engine child was reaped |
signal | SIGTERM or SIGINT |
kill_timeout | true 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):
| Field | Meaning |
|---|---|
origin | Absolute path of the trail directory |
size | Number of receipt files covered |
root | sha256: of the head receipt file bytes (same digest as HEAD) |
witnessed_at | ISO 8601 UTC when the witness signed |
witness_id | Fingerprint of the witness verifying key |
signature_hex | Ed25519 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
| Receipt | verify-attest | --strict | --proof | cosign |
|---|---|---|---|---|
| 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 fields | as 2.0 | ✅ | ✅ (same v2 predicate URI) |
7. Files and locations
| Path | Purpose | Mode |
|---|---|---|
~/.local/share/erebus/machine/machine.key (or $ERE_MACHINE_DIR) | machine seed, never rotated implicitly | 0600 |
…/machine/machine.cert.json | registry certificate | 0644 |
~/.local/share/erebus/witness/witness.key (or $ERE_WITNESS_KEY / $ERE_WITNESS_DIR) | local trail witness seed (R1) | 0600 |
<trail>/HEAD.witness.json | signed trail-head checkpoint while offline | 0644 |
<bundle>.erebus.attest | latest receipt | 0644 |
<bundle>.erebus.attest.d/ | hash-chained trail | 0644 |
<receipt>.proof.json | transparency-log proof (erebus-log-proof/v1) | 0644 |
8. Threat notes
- A stolen operator key produces receipts that verify — but not
--strictones (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 /machineslists 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 answersqueuedwithout sending the mail is not caught); a tool the agent calls around the boundary is not observed (execution_without_callcannot see it,result_without_executioncan) unless the run is confined withERE_EGRESSto the boundary’s port and the receipt showsegress— 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-dirforging 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.
| Segment | Why they need evidence, not logs |
|---|---|
| Defense and public administration | air-gapped by regulation; verification must work without calling a vendor; supply-chain traceability (SBOM, signatures) is already required |
| Industrial edge and vehicles | agents on hardware in the field; software update traceability (UNECE R156) |
| AI companies under the EU AI Act | high-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 asks | App logs | LLM tracing / observability | Build provenance (Sigstore, SLSA) | TEE attestation | erebus |
|---|---|---|---|---|---|
| Which exact code and weights ran? | ❌ | ❌ | built artifact only | ✅ measured at launch | ✅ bundle, payload and rootfs hashes, signed per run |
| On which machine? | hostname string | agent metadata | ❌ | ✅ hardware identity | ✅ enrolled machine key co-signs, certified by the registry |
| When, provably? | local clock | vendor clock | at build time | at 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? | yes | yes (their account) | n/a | n/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? | no | no | no | yes | no |
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 (
gccormusl-tools) — required bybackhand(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:
| Key | Who gives it to you | What it looks like |
|---|---|---|
| Operator public key | the operator (contract annex, signed email, call) — the value erebus keygen printed | 64 hex, or its sha256: fingerprint |
| Transparency-log key | the registry, at GET /log/key → public_key_hex | 64 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:
| Line | Check |
|---|---|
bundle | SHA-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. |
receipts | Each receipt names the SHA-256 of the previous one inside its signed statement: nothing was reordered, inserted or removed inside the trail. |
signatures | DSSE signature by a pinned operator key; machine co-signature by a host key whose certificate is signed by the pinned registry key (--strict). |
log | Each receipt is a leaf of the registry’s Merkle transparency log: inclusion proof up to a checkpoint signed by the pinned log key. |
files | Every 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 / registry | Every 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_hashare the SHA-256 of the request and response bodies: ask the operator for them and hash them yourself. For a batch run,input_hashcovers the launch arguments,stdin_hash(when present) the whole piped stdin, andoutput_hashcombines 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, optionalbundle/. - 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:
- detects the
pythonruntime and entrypoint (/app/app.py); - embeds the build machine’s
python3interpreter; - embeds the stdlib (
/usr/lib/pythonX.Y); - resolves
.sodependencies via the ELF analyzer (libc, etc.); - 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:
- a vendored
site-packages/directory at the app root (copied with the app); - a virtualenv
.venv/orvenv/at the app root → itslib/pythonX.Y/site-packages(orLib/site-packageson 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-packagesin the rootfs; - declares
PYTHONPATH=${ROOTFS}/app/site-packagesin metadata (the${ROOTFS}token is resolved by the launcher — see Launcher); - the stub also injects that
PYTHONPATHat runtime whenapp/site-packagesexists, so vendored-only apps work even without a prior metadata entry; - runs the ELF analyzer on
.sofiles 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:
- Django —
manage.py→python3 /app/manage.py runserver 0.0.0.0:8000 - Streamlit —
streamlitin requirements →streamlit run /app/app.py --server.port 8501 --server.address 0.0.0.0 - FastAPI/ASGI —
uvicorn/gunicornin requirements orpyproject.toml→uvicorn module:app --host 0.0.0.0 --port 8000 - PEP 621
[project.scripts]—start = "..."inpyproject.toml - Python module —
pyproject.tomlwithtool.fastapi.entrypointor package with__main__.py→python3 -m module - Named script — first of
app.py,main.py,__main__.py,server.py→python3 /app/<script> - Shebang script — extensionless file with
#!/usr/bin/env python3(orpython) →python3 /app/<name>
Unsupported bundles
- PyInstaller onefile and
.pyzzipapps 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, simplegather) works under the default sandbox. Seeexamples/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:
- detects the binary via magic bytes (
\x7fELF,MZ, Mach-O); - resolves shared library dependencies via the ELF analyzer (Linux ELF);
- packages the binary + libraries into the app layer;
- compresses and assembles the
.erebus.
How it works
No runtime interpreter is embedded. The launcher:
- extracts the binary and its
.sodependencies to the cache; - sets
LD_LIBRARY_PATHto include the extracted library directories (Unix); 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. SeeAGENTS.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
.sodependencies) 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
.pyzzipapps 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:
| Runtime | Priority order |
|---|---|
| Python | uv > poetry > pipenv > pip |
ELF dependency resolution
For dynamic binaries, the builder reads ELF64 program headers directly
(without calling ldd):
- Iterates
Program Headersto findPT_DYNAMICandPT_INTERP - Extracts
DT_NEEDEDentries (required libraries) andDT_RUNPATH(search paths) - For each library, searches standard paths (
/lib,/usr/lib,/lib64, etc.),LD_LIBRARY_PATH, andDT_RUNPATH - Recursively resolves dependencies of each found
.so - 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:
-
CLI (
erebus ./my_app) — four commands: Build · Run · Inspect · Clean. This is the user surface. -
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.
- Analyzer: Rust ELF parser (DT_NEEDED, DT_RUNPATH, transitive
resolution), runtime detection, and hidden dependency detection
(subprocess,
-
.erebusFormat — the central layer: ELF Launcher · zstd Payload · JSON Metadata · Magic + SHA-256. This is the file format described in reference. -
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.
- Cache:
-
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:
| Element | Target | Current |
|---|---|---|
| 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.
| Layer | Static .erebus | SISR .erebus |
|---|---|---|
| Launcher | same | same (unchanged) |
| SISR engine | absent | embedded, dormant by default |
| Payload (SquashFS) | same | same |
| Update path | rebuild + redistribute | self-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.
Footer layout (v3/v4/v5, 92 bytes)
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.
| Field | Offset | Type | Size | Description |
|---|---|---|---|---|
sig_offset | 0 | u64 | 8 | absolute offset of signature block (0 if unsigned) |
magic | 8 | bytes | 5 | "ERE\x01" |
format_version | 13 | u8 | 1 | format version (3, 4, or 5) |
arch | 14 | u8 | 1 | 0x01=x86_64, 0x02=aarch64 |
flags | 15 | u8 | 1 | bit0=signed, bit1=encrypted |
payload_offset | 16 | u64 | 8 | absolute offset of payload |
payload_csize | 24 | u64 | 8 | compressed size of all layers |
payload_usize | 32 | u64 | 8 | v2/v3: unused; v4/v5: crypto_suite (0=none, 1=AES-256-GCM) |
payload_sha256 | 40 | bytes | 32 | SHA-256(payload ‖ metadata) |
meta_offset | 72 | u64 | 8 | absolute offset of metadata |
meta_size | 80 | u64 | 8 | metadata size in bytes |
footer_magic | 88 | u32 | 4 | 0xBEEFCAFE 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.
Design decision: footer at end, not header at start
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:
- Custom header before ELF — rejected. The kernel would refuse to execute
the file.
binfmt_misccould work but requires root on every target machine. - Embed metadata inside ELF sections — rejected. The kernel loads ELF sections into memory; our metadata would consume address space and confuse debuggers.
- 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:
- Grow the footer to 92 bytes (add
sig_offsetfield) — rejected. A v2 launcher reading 84 bytes would see truncated data and likely crash or silently misparse. - Append the signature block after the footer — rejected. The launcher reads backwards from EOF; data after the footer is invisible to it.
- 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 upsig_offsetfrom 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:
| Field | Type | Size | Description |
|---|---|---|---|
sig_size | u32le | 4 | always 64 (Ed25519 sig) |
signature | bytes | 64 | Ed25519 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 injectsLD_LIBRARY_PATHseparately 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)
| Version | Changes |
|---|---|
| v1 | Monolithic zstd(tar) payload |
| v2 | Layered payload (runtime + app), incremental rebuild |
| v3 | Ed25519 signatures (92-byte footer with sig_offset) |
| v4 | AES-256-GCM payload encryption (crypto_suite in footer) |
| v5 | SquashFS 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.rsorchestrates the build pipeline
The three steps
1. Analysis — detect.rs + pkgmgr.rs
detect.rsdetects 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 callingldd): readsPT_DYNAMICheaders, extractsDT_NEEDED,DT_RUNPATH,PT_INTERP, and resolves transitive dependencies in standard system search paths (/lib,/usr/lib,/lib64, etc.). Includes the dynamic loaderld-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 execution | Subsequent executions | |
|---|---|---|
| Cache | missing → extraction | present (.ready) → reused |
| Message | cold start: extracting... | warm start: cache hit {hash} |
| Cost | zstd decompression + disk write | near-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 cache | Build cache | |
|---|---|---|
| Path | ~/.cache/erebus/{sha256}/ | ~/.cache/erebus/build/{hash}.zst |
| Side | target machine (at run) | build machine (at build) |
| Content | extracted rootfs, ready to use | compressed reusable layers |
| Role | avoid re-extracting on every launch | avoid 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.
Level 2 — User namespaces ✅ (implemented, recommended)
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):
unshare(CLONE_NEWUSER | CLONE_NEWNS)— new user + mount namespaces- UID/GID mapping: write to
/proc/self/uid_map,setgroups,gid_map - Bind-mount the rootfs onto itself (
MS_BIND | MS_REC, required bypivot_root) pivot_root(rootfs, rootfs/.old_root)+umount2("/.old_root", MNT_DETACH)std::env::set_current_dir("/")— CWD in the new root- Install seccomp-bpf denylist (blocks ~14 dangerous syscalls)
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 sopivot_rootresolves them correctly. - The ELF analyzer deduplicates
.sofiles that point to the same real file via different symlinks, avoiding duplicates in the rootfs. /etc/hostsis 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.erebusformat: only theisolationmetadata 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
| Flag | Description |
|---|---|
-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) |
--seccomp | Install a seccomp BPF denylist at runtime |
--landlock | Enable Landlock LSM filesystem sandbox |
--encrypt | Encrypt the payload (AES-256-GCM, key derived from signing seed) |
--squashfs | Use SquashFS instead of zstd+tar for the payload |
--no-install | Skip dependency installation |
--env-file <PATH> | Bake in a KEY=VALUE env file |
--env KEY=VALUE | Set an env var (repeatable) |
--define KEY=VALUE | Build-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-run | Print the build plan without building |
--json | Emit a JSON build result on stdout |
-v/--quiet | Verbose / quiet output |
.envand secrets: an app directory containing a.envfile is rejected unless you pass--include <app>/.envexplicitly. 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/--envto bake configuration into the binary metadata, and reserve--include .envfor cases where bundling the file is the app’s intended deployment model.
SISR options (incremental self-updates)
| Flag | Description |
|---|---|
--enable-sisr | Enable 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 signedXBMRremote 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
- Detects the runtime and installs dependencies (
--no-installto skip). - Copies the app into a rootfs, optionally embedding an interpreter.
- Creates a deterministic
zstd+tarpayload (or SquashFS). - Builds metadata (env, entrypoint, hashes,
update_url). - Assembles the binary — with
--enable-sisrthe 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
erebus sign,erebus verify,erebus keygen(erebus <command> --help); receipts: Quickstart §6- Incremental Updates (SISR)
- User-facing update workflow
Self-Incremental Sovereign Reconstruction (SISR)
Advanced, opt-in. SISR is an extension of erebus — it is not the core value proposition. A default
erebus buildproduces 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) | |
|---|---|---|
| Build | erebus build ./app -o app.erebus | erebus build ./app -o app.erebus --self-update |
| Contents | launcher + payload (SquashFS) | launcher + payload + embedded SISR engine |
| Update | rebuild + redistribute | ./app.erebus update — self-rebuilds from signed deltas |
| Toolchain on target | none | still none (engine is embedded) |
| Behavior | static container | static container by default, updatable on demand |
| Trust | signature at launch | signature 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:
- Interrogate — the binary fetches the remote manifest for its channel.
- Download — it pulls only the blocks that changed since
v1.0(content-defined chunks, see the delta manifest spec). - 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).
- Rebuild & swap — the new binary is assembled in a staging location,
verified end-to-end, then committed atomically. An interruption leaves
v1.0intact 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
erebusCLI, 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
- Delta manifest format — how an update is described and verified.
- Incremental updates guide — how to build, sign, and deploy an updatable binary.
- SISR conceptual specification — the invariants and trust model behind this extension.
Rollback & Resilience (SISR health gate)
Status: implemented (Mission 8).
erebus-coresisr::health+sisr::resilience, wired intostub/src/main.rs, verified end-to-end bystub/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,
.baknever 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).
| Field | Meaning |
|---|---|
state | pending → healthy / quarantined |
attempts | consecutive installs of this version that reached the health gate |
last_checked / last_crashed | RFC 3339 timestamps |
Rules:
beginrecords an install aspendingwithout resetting the failure counter — repeated installs of the same broken version accumulate.confirmmarks the version healthy (exit 0 or survived the startup window).record_failureincrementsattempts; the version is quarantined onceattempts >= 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)
| Variable | Default | Meaning |
|---|---|---|
EREBUS_HEALTH_TIMEOUT_MS | 10000 | startup window the app must survive |
EREBUS_HEALTH_MAX_ATTEMPTS | 3 | installs before quarantine (0 = never quarantine) |
Failure table
| Situation | Outcome |
|---|---|
| Update applies, new version exits 0 | confirmed healthy, .bak discarded, binary kept |
| Update applies, new version crashes / non-zero | recorded; .bak restored atomically; previous version runs |
| Same version fails repeatedly | quarantined; log printed |
| Re-install of a quarantined version | refused 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.
Related
- Runtime Launcher — the launcher flow this gate slots into.
- SISR: Self-Incremental Sovereign Reconstruction — trust model.
- Incremental Updates (SISR) — publisher workflow.
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
.erebusby 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 path | Rebuild + redistribute | Self-rebuild from signed delta |
| Requires toolchain | Only at build time | Never |
| Trust anchor | Signature verified once at launch | Signature chain re-verified at every reconstruction |
| Binary identity | Fixed by its bytes | Reconstructed 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
erebusCLI, - 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 |
+-----------------------------------------------------------------+
- Entrypoint Launcher — the existing stub. Reads footer, verifies integrity/signature, extracts, execs. Unchanged behavior for normal runs.
- Embedded SISR Engine — dormant code path that can rebuild the binary from signed blocks. Never runs during a default launch.
- 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
(
createdtimestamp 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_manifestfirst: any failure anywhere in the chain stops the reconstruction immediately. No partial assembly is ever observed.assembleis pure: it produces the candidate bytes without touching the live binary. The only side-effecting step iscommit, 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
.erebusexecutables (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
| Attack | Scenario | Primary defense |
|---|---|---|
| Replay | Force the binary to re-assemble onto an old, vulnerable version | Monotonic signed anti-rollback index (§5.2) |
| Malicious block injection | Inject a malicious block into the local block cache | Content-addressed cache: wrong hash ⇒ wrong key ⇒ rejected (§6.2) |
| Manifest spoofing | Present a forged/edited manifest | Ed25519 chain of trust + non-repudiation (§5.1) |
| TOCTOU on cache | Swap cache content between check and use | Cache is re-validated against the signed manifest at assembly; content-addressing makes swaps detectable |
| Downgrade format | Try to reconstruct an older, weaker format | min_format_version in TrustPolicy |
| Unsigned delta | Inject an unsigned or partially signed delta | allow_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
- Security model — in particular the new section Chain of Trust for Local Rebuilding
.erebusformat — the format this engine will eventually reconstruct- The Launcher (stub) — the existing bootstrap responsibility
Builder Pipeline
Status: implemented (
erebus-coreassembly+sisr_stage). Describes how a.erebusis assembled, how the optionalSISRstage 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:
- Chunk the payload with
FastCDCatchunk_target_size(sisr_stage::chunk_payload), hashing each chunk with SHA-256. - Serialize the
DeltaManifest(magicXBMD, chunk table). - Merkle root over the chunk hashes (
sisr_stage::merkle_root). - Sign
merkle_root ‖ manifest_byteswith the optional Ed25519 key (sisr_stage::sign); all-zeros signature when unsigned. - Inject the manifest and the fixed 110-byte
SisrFooterExtbefore the standard footer; setFLAG_SISRin the footerflagsbyte. - Write the remote manifest to
<name>.erebus.manifest(RemoteManifest::to_bytes):XBMRmagic +merkle_root+ signature + embeddedDeltaManifest, 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 ofassemble_erebus. - Reader/writer symmetry. The extension is located by the reader at
file_len − footer_size − 110(immediately before the footer) and the manifest viachunk_table_offset— the writer places the manifest between the metadata and the extension, satisfying thetable_end ≤ ext_startbound. - No runtime coupling. The launcher is untouched; the classic and SISR files both boot through the stock footer.
- Zeroize. The
SigningKey(aned25519-dalektype) 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.
Related
- SISR: Self-Incremental Sovereign Reconstruction — the trust model the stage serves.
.erebusFormat v2 — SISR extension — exact byte layout of the manifest and footer extension.- The Builder — CLI flow that feeds this packager.
- Incremental Updates (SISR) — consuming the remote manifest for delta reconstruction.
Runtime Launcher: SISR self-update
Status: implemented (
erebus-coresisr::engine+sisr::swap, wired intostub/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_MANIFESTpoints 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> theupdate_urlembedded at build time (erebus build --update-url).--erebus-versionprints 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 thechunks/directory next to the manifest (<manifest-dir>/chunks/<64-hex-sha256>), served byDirectoryChunkFetcher.--erebus-update [URL]— fetches<URL>/manifestand<URL>/chunks/<hex>over HTTPS viaHttpChunkFetcher, resolving the base URL from the positional argument, then$EREBUS_UPDATE_URL, then the embeddedupdate_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:
RemoteManifest::verify_any(trusted_keys)— the Ed25519 signature overmerkle_root ‖ manifest_bytesmust verify against at least one key in the trusted-keys directory (same directory the embedded binary signature uses, seeload_trusted_keys).RemoteManifest::verify_merkle()— the stated Merkle root must match the chunk table, so a signer error cannot smuggle a mismatched root.- 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. - 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 unlessERE_SISR_ALLOW_UNSIGNED=1is 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::createalone would yield0o644); - after the swap the launcher re-opens the canonical real path returned by
the engine —
/proc/self/execan 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_storemarks 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
| Piece | Handling |
|---|---|
| stub (0..payload_offset) | copied verbatim from the current binary |
| payload chunks | reused when the current binary already has hash-verified bytes, else fetched + hash-verified |
| metadata | copied verbatim from the current binary (deltas never change metadata) |
DeltaManifest + SisrFooterExt | rebuilt from the fetched manifest and chunk table |
| footer | rebuilt: flags = (old & ~FLAG_SIGNED) | FLAG_SISR, payload SHA-256 = SHA-256(payload ‖ meta), signature offset zeroed |
SisrFooterExt.signature | carries 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
| Situation | Outcome |
|---|---|
EREBUS_SISR_MANIFEST unreadable / bad magic | launcher exits, binary untouched |
| Manifest URL unreachable / non-2xx | update 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 mismatch | update refused before any write |
| Fetched chunk wrong length or SHA-256 | engine errors, binary untouched |
| Embedded SISR section unsigned / untrusted / payload mismatch at cold start | launcher 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 times | quarantined; previous version runs |
| Re-install of a quarantined version | refused before any snapshot or write |
Related
- Builder Pipeline — where the manifest and chunks are produced and signed.
- SISR: Self-Incremental Sovereign Reconstruction — trust model and invariants.
.erebusFormat v2 — SISR extension — byte layout of the remote manifest and the footer extension.- Incremental Updates (SISR) — the end-to-end workflow the launcher completes.
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):
putfails unlessSHA-256(data) == hash; the on-disk copy is re-read and re-verified beforeputreturns.getrecomputes 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(DirectoryChunkFetcherreads<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, atomicrename) 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
| Concern | Mechanism |
|---|---|
| Bit rot on disk | SHA-256 re-verified on every ObjectStore::get |
| Malicious block injection | Content-addressing: tampered bytes ⇒ different hash ⇒ never found |
| Corrupt base during splice | Footer magic + offset bounds validated before writing |
| Replay / forgery | Ed25519 signature over merkle_root ‖ manifest (embedded + remote) |
Related
- SISR: Self-Incremental Sovereign Reconstruction — the invariants and trust model these primitives serve.
- Delta manifest format — the structure that references chunks and drives the assembler.
.erebusFormat v2 — SISR extension — where the binaryDeltaManifestandSisrFooterExtlive inside the file.
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
.erebusformat.
An incremental update is driven by a manifest — a small, signed document that answers three questions:
- What is this update, and for which binary?
- What changed, and where (the delta / block list)?
- 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_indexis 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_sha256pins the update to a specific source binary. A manifest downloaded forv1.0is refused by av1.2binary. - Exact outcome.
target_sha256lets 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:
- 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.
- 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.indexis 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) | |
|---|---|---|
| Role | describes a binary | describes how to build the next binary |
| Location | embedded at end of the file | external (fetched from a remote) |
| Signed | Ed25519 (optional today) | Ed25519 (always) |
| Versioning | format version in footer | schema 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
.erebusfile additionally embeds a compact binary chunk index — the same block list serialized asDeltaManifest— so the runtime can verify the embedded content before it is ever assembled. See.erebusFormat v2 — SISR extension.
References
- SISR overview — the two erebus models and the full reconstruction flow.
- SISR conceptual specification — invariants and trust model (this spec derives from it).
- Chain of Trust for Local Rebuilding
.erebus Format v2 — SISR extension
Status: implemented (
erebus-coresisr_header+manifestmodules). Specifies the.erebuslayout enriched with theSISRheader 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 withoutSISR.- Legacy decoders read the footer backwards from EOF; the
SISRblocks sit before it and are never seen. - Presence is signaled by the
FLAG_SISRbit (0x04) of the footerflagsbyte. Existing readers that only testFLAG_SIGNED/FLAG_ENCRYPTEDare unaffected; a legacy file without the bit decodes transparently. - A new reader locates the extension at
file_len - footer_size - 110, wherefooter_sizeis 84 (v2) or 92 (v3+), fromFooter::footer_size().
SISR footer extension (110 bytes, little-endian)
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 2 | sisr_version | extension schema version (1); 0 = absent |
| 2 | 8 | chunk_table_offset | absolute file offset of the DeltaManifest |
| 10 | 4 | chunk_table_len | byte length of the DeltaManifest |
| 14 | 32 | merkle_root | Merkle root over the payload chunk hashes |
| 46 | 64 | signature | Ed25519 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)
| offset | size | field |
|---|---|---|
| 0 | 4 | magic XBMD |
| 4 | 1 | version (1) |
| 5 | 3 | reserved (zero) |
| 8 | 4 | chunk_count |
| 12 | 8 | payload_len |
| 20 | 36·n | ChunkEntry 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_lenis 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_countexactly; count-derived sizes are computed with checked arithmetic before the chunk vector is allocated, so a forgedchunk_countfails without allocating or over-reading. - Unknown schema versions are rejected (same rule as
.erebusformat 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.
Related
Incremental Updates (SISR)
Advanced, opt-in. This guide covers the SISR extension: making a
.erebusable to update itself from signed deltas. If you don’t need self-updates, ignore this page — a plainerebus buildalready gives you a static, self-contained binary.Status: implemented —
erebus build --enable-sisrproduces an updatable binary and its signed manifest, and./app.erebus --erebus-updateapplies 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:
- the binary fetches its manifest from the remote channel (HTTPS),
- it verifies the Ed25519 signature before doing anything else,
- it verifies
base_sha256matches the current binary (right version?), - it verifies the Merkle root of the fetched chunk table,
- it downloads only the chunks it does not already have and verifies each (SHA-256 + length),
- 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)
| Situation | Outcome |
|---|---|
| Manifest not signed / wrong key | update refused, binary untouched |
Wrong base_sha256 | update refused (not applicable to this version) |
| Tampered chunk in cache | chunk rejected by content hash |
| Merkle root mismatch | candidate discarded before commit |
Power loss / SIGKILL mid-update | previous 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
- SISR overview — classic vs SISR at a glance.
- Delta manifest format — what the engine fetches and verifies.
- SISR conceptual specification — the invariants and trust model.
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:
| Artifact | Purpose |
|---|---|
my_app.erebus | Self-extracting binary; self-updates against the channel |
my_app.erebus.manifest | Signed 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
| Flag | Effect |
|---|---|
--erebus-update [URL] | Check the channel, verify, apply the delta, print stats, exit |
--erebus-version | Print 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:
- 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. - The Merkle root must match the manifest’s own chunk table.
- Every chunk — reused or downloaded — must SHA-256 to its manifest entry; wrong length or hash is rejected on the spot.
- The swap is atomic: any failure (bad signature, missing chunk, power loss) leaves the previous binary intact and runnable.
- 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
| Situation | Outcome |
|---|---|
| Manifest missing / bad magic / bad URL | update refused, binary untouched |
| Signature fails (key not trusted) | update refused before any write |
| Merkle root mismatch | update refused before any write |
| Chunk wrong length or SHA-256 | engine errors, binary untouched |
| Chunk 404s on the server | engine errors, binary untouched |
| Interruption mid-update | previous binary intact |
| New version crashes at startup | .bak restored, previous version runs |
Release fails EREBUS_HEALTH_MAX_ATTEMPTS times | quarantined, installs refused |
| Re-install of a quarantined release | refused 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
- Incremental Updates (SISR) — the full workflow for publishers.
- Runtime Launcher — what the launcher does step by step.
erebus build— the builder flags.
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_SISRset → the file embeds a delta manifest;--erebus-updateand theEREBUS_SISR_MANIFESTpath are available.FLAG_SISRclear → classic extraction. Cache keying, integrity verification (SHA-256 overpayload ‖ 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_sizeare 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
Option A — full rebuild (recommended for new releases)
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
.erebusfooter 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 forverify-attest --request-body/--response-body(honest accept, forged refuse). Documents--expected-output-hashnext to the offline pack.attest-tool http --expected-output-hash: e2e that a wrong golden digest writes no receipt;verify-attest --expected-output-hashmismatch is exit 2.
Fixed
scripts/yc_demo.shruns on stock macOS without GNUtimeout(gtimeoutor perl alarm fallback).
[0.6.3] — 2026-10-17
Fixed
- Windows release build:
LocalFreereceives the SID string pointer as an untyped allocation, soerebus-stubcompiles forx86_64-pc-windows-gnu.
Changed
- Registry bearer comes from
--token-fileorERE_TOKEN. A--tokenflag would show the secret inpsand 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-stubdownloads the matching stub from the GitHub release and checks it againstchecksums.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-keysigns every tool call the engine returns (name and call id in clear,sha256of the arguments) intoexecution.request.tool_calls, for chat completions (JSON, SSE deltas, legacyfunction_call) and the Responses API. A response that cannot be fully parsed is flaggedtool_calls_incompleteinstead 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 signedoutput_hashand 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, replayedtool_history,tool_resultsas call id + content digest).verify-attest --chainlinks every result to the call that requested it and reports injected results, rewritten history, changed results and calls to unoffered tools;--require-tool-linkagemakes them fatal (exit 2).verify-attest <receipt> --request-body FILEre-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) anderebus 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=]DIRmatches 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-executionmakes them fatal (exit 2). Tool calls gain an optionalarguments_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 theErebus-Call-IdHTTP header is signed astool_invocation.call_id. A bound execution matches only its call, so identical calls executed out of order no longer yield a spuriousresult_not_from_tool, and a tool that ran the call with other arguments is reportedexecution_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. Reportstool_trust: separate|shared; shared trust is flagged in the human output.- Egress confinement:
ERE_EGRESS=PORT[,PORT…]|nonelimits the payload’s outbound TCP to the given ports (macOS Seatbelt:localhostonly; Linux 6.7+: Landlock TCP connect rules plus a seccomp filter refusing UDP, raw and packet sockets, TCP Fast Open,io_uringand foreign ABIs). Signed asexecution.egress; an invalid policy or a kernel without Landlock ABI 4 refuses the launch.scripts/egress_e2e.sh(Linux) andscripts/macos_slice.shcheck it on a real bundle against a control. erebus evidence export <bundle> -o pack.zipanderebus 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 orNOT PINNED.- Evidence packs carry
anchors/<receipt>.checkpoint, the exact bytes the RFC 3161 token covers, soopenssl ts -verify -datachecks 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_NONBLOCKfrom the listener, so a probe that had not yet written its request got a reset instead of200. Accepted sockets are now blocking with a 2 s read timeout. - SISR e2e mock server: same inheritance made
e2e_sisr_mainandupgrade_migrationfail randomly on macOS (“Peer disconnected”). erebus builddetects 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 buildno longer stages a Linux glibc base (and warns about it) for non-ELF payloads.verify-evidence --operator-keyaccepts the public key hex thaterebus keygenprints, as well as thesha256:fingerprint.attest --pushalso stores the inclusion proof beside the trail entry, so trails built withattestcarry their proofs likeserve --pushones.- Spec and docs: a batch run’s
input_hashcovers the launch vector (entrypoint + arguments), not stdin, andoutput_hashneeds Linux isolation ≥ 2; the spec claimed stdin. Quickstart trusted-keys path fixed.
Fixed (macOS, ROADMAP P1-5)
--landlockbundles 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 privateTMPDIR.- With
ERE_ATTEST_KEY, macOS receipts now carryoutput_hashandexit_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 underpipefail.
Added (batch stdin)
- Spec 2.1
execution.stdin_hash: with capture on, a non-terminal stdin is relayed by the supervisor and itssha256signed, 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-modelnow 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.jsonsidecars, 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 theunshare(CLONE_NEWUSER)that follows was refused with EINVAL.enter_usernsnow waits for/proc/self/taskto drain and retries the unshare;scripts/chaos_extreme.shgained a 40-launch cold-start storm that reproduces the window under load. erebus buildright after a release install: the CLI now finds theerebus-stubshipped next to theerebusbinary (host-target builds). It used to search only source-tree build dirs andPATH, 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;signaturemirrors the operator entry so 1.x consumers keep working.verify-attestaccepts 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 keygenwrites<fp>.pem,erebus attest --envelope FILEwrites the bare envelope;cosign verify-blob-attestationverifies it (exercised in CI). - Transparency log — registry
attestations/log.sqliteMerkle tree (RFC 6962/9162) with signed checkpoints,GET /log/{checkpoint,proof, consistency,key}, RFC 3161 anchoring (--tsa-url,--anchor-every);erebus attest --pushstores<receipt>.proof.json;verify-attest --proof [--log-key]verifies it offline. - Warm-cache re-verification —
rootfs.merklemanifest written at extraction; every warm start re-hashes the rootfs and re-extracts on drift;execution.rootfs_rootis 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-attesthardening: display-copy consistency check,--bundlebinding, fingerprint check.- Proof chain in CI —
.github/workflows/proof.yml(README badge) runsscripts/test_transparency_log.py, the fullscripts/yc_demo.sh(sandbox on, enroll,--strict, cosign, four chaos harnesses) andscripts/chaos_extreme.shon every push and daily.
Fixed
erebus serve --llama-server PATHno longer requires allama-serveron 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 spellingpredicate_typeis 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).erebusto the v2 format by injecting the delta manifest andSISRheader, 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
.erebusfooter 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.mdat the repository root: product state, audit findings, priorities and business model. This page is a summary and may lag behind it.
Priorities are strict
| Priority | Theme | State |
|---|---|---|
| P0 | Make 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 interop | complete (2026-09-17) |
| P1 | Make the claims continuously proven: demo and chaos batteries in CI (proof.yml), evidence export, one proof-first story, registry hygiene, macOS validated on Apple Silicon | in progress |
| P2 | Differentiators: erebus policy (fail-closed rules enforced by the launcher), EU AI Act / SOC 2 evidence mapping, fleet dashboard, Windows sandbox | after 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
- T1 — the whole proof chain re-proven in CI on every commit. Done.
- T2 — internal pilot: a real agent behind
serve --attest-key --push, several enrolled machines, a public TSA-anchored registry, zero verification failures. Not done. - T3 — a design partner in the target segments (defense / administration, industrial edge).
- 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:
| Tier | Includes |
|---|---|
| Free | public packages, local receipts, self-hosted registry |
| Pro · $29/mo | private packages, hosted transparency log, anchored checkpoints |
| Team · $99/mo | + RBAC, audit log, evidence packs, webhooks |
| Enterprise · $24k/yr | air-gapped mirror + offline verifier, SSO, SLA, compliance reports |
| Enterprise+ · $50/node/yr | per-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:
- Manifest spoofing — a forged or edited manifest passes off arbitrary content as an official update.
- Replay — a signed but old delta rolls the binary back to a vulnerable version.
- Cache poisoning — a malicious block injected into the local reconstruction cache replaces legitimate content.
Defense — three linked mechanisms:
-
Signed chain of trust (non-repudiation). A
.erebusonly 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. -
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.
-
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
tarextraction 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> mismatchotherwise); - the operator key is looked up by fingerprint, so a trusted machine key can never satisfy the operator check;
--strictdemands the machine co-signature, a signedexecution.machine_idmatching the machine block, and a certificate from a trusted registry key;--proofre-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;
--strictrejects 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/parseandserialize/parseround-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.