Specification · raw markdown · v1 spec
Ampoule run predicate, v2 (and v1)
Predicate type: https://ampoule.dev/attestation/run/v2 (current).
Receipts signed before v2 carry https://ampoule.dev/attestation/run/v1;
see v2: differences from v1.
The URI is permanent: every signed receipt carries it. A breaking change to
the fields gets a new version (/v2), never an edit to this one.
An in-toto Statement v1 with this predicate records one execution of untrusted code inside a sealed Ampoule micro-VM: which kernel ran it, what went in, what came out, and how much work it took. It is carried in a DSSE envelope signed with Ed25519.
Statement
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [{ "name": "output", "digest": { "sha256": "<output_sha256>" } }],
"predicateType": "https://ampoule.dev/attestation/run/v2",
"predicate": {
"version": 1,
"kernel_sha256": "60ba9149…",
"vmm": "qemu",
"runtime": "js",
"flags": 260,
"deterministic": true,
"seed": 7,
"payload_sha256": "bf75c921…",
"output_sha256": "12cece5a…",
"fuel": 14354058,
"status": 0
}
}
The subject is the output, the artifact the run produced. Its digest must
equal predicate.output_sha256; verifiers reject a statement where they differ.
Predicate fields
| field | meaning |
|---|---|
version |
Receipt format version: the JSON integer 1, fixed for v1 and v2. Any other value or type (2, "1", 1.0) is rejected; a format change gets a new predicate type, not a new version. |
kernel_sha256 |
Required. SHA-256 of the kernel ELF the VM booted. The language runtimes (QuickJS, CPython) are embedded in the kernel, so this pins them too. |
vmm |
qemu or firecracker. Informational: deterministic runs give the same output and fuel on both. |
runtime |
rhai, wasm, js or python. |
flags |
Request flags as sent in the mailbox (runtime bit, 0x100 = deterministic). |
deterministic |
Clock and randomness derived from seed (the WASI realtime clock reads 2026-01-01T00:00:00Z + seed s + 1 ms per read), a runaway ends on a fixed budget that replay reproduces exactly: wasmi fuel for wasm (20G), js (2T) and python (2T, warm and cold path alike), an operation count for rhai. A host wall-clock cutoff (js 20 s, python 30 s, wasm 20 s) remains as a safety net for hosts too slow to reach the budget; a run it stops gets terminated and is not replayable. Only these runs can be re-executed to the same result. |
seed |
The seed used for clock and randomness. |
payload_sha256 |
SHA-256 of the exact request bytes: the source for rhai/js/python; for wasm, u32le module_len ‖ module ‖ input. Computed by the guest and checked by the host. |
output_sha256 |
SHA-256 of the exact output bytes, computed by the guest and checked by the host. See Output bytes for what those bytes are. |
fuel |
Work done: wasmi fuel consumed for wasm/js/python, Rhai operations counted by the engine for rhai. Wasmi fuel is a coarse cost measure (charged per block of instructions at weighted costs, plus per byte of bulk memory work), not an instruction count; two different inputs can use the same fuel. For deterministic runs, part of what replay must reproduce. |
status |
The kernel's result code for the run, not the program's exit code: 0 success, 1 program error (an uncaught exception, any nonzero exit, a trap or a limit), 2 bad request or runtime unavailable, other values are limits or faults. A Python program that exits with code 2 gives status 1, as does an uncaught exception (CPython exits with 1): the kernel maps every nonzero WASI exit code N to status 1 and appends !! exit code N to the output, so the program's own code is in the output, not in status. Tools print it as status 1 (program error). A signed receipt with a nonzero status attests a failed run, not a successful one: ampoule-verify prints RUN FAILED: signature valid, status N (meaning) and exits 4, verify --replay prints REPLAY MATCHED, RUN FAILED: status N and exits 4 when the rerun matches (it never says REPLAY OK for such a receipt), the MCP server's receipt-check tool answers RUN FAILED: signature valid, status N (meaning) (…), and the verifier library reports a failed run, never a verified one. terminated outranks it (exit 3): a stopped run's status describes the stop. |
stop |
Optional; absent on every receipt from an earlier kernel and on every run that did not use up its budget. "fuel": the run used up its fuel budget (wasmi fuel for wasm, js and python; the operation limit for rhai), i.e. a runaway, not a crash (response header word at offset 112 = 2). Such a run is deterministic, so it stays replayable: status is 1, and the verdict is the status verdict (ampoule-verify RUN FAILED, exit 4; verify --replay REPLAY MATCHED, RUN FAILED, exit 4, and replay must reproduce the stop: a rerun that does or does not stop on fuel where the receipt says otherwise is REPLAY FAILED: stop differs). Tools print it as a detail line (stop fuel: ... in ampoule-verify, stop: fuel: ... in the MCP receipt-check tool, stop fuel matches receipt in replay); the result line is the same as for the same receipt without stop. It is a separate field, not a terminated value, because any terminated key means not replayable. Verifiers released before this field was added ignore it (unknown extra field) and report the same RUN FAILED, exit 4. Strict: any value other than "fuel", a status other than 1, or stop together with terminated is FAILED (exit 1), and the value is never printed as it is. Inside the signed statement, so removing or adding it without re-signing breaks the signature. |
terminated |
Optional; absent for a run that ended normally. "wall_timeout": the guest was stopped by the host wall-clock safety net, not by fuel (response header word at offset 112 = 1). Whether that fires depends on host speed, so the run is not replayable even when deterministic is true. Verifiers must never report such a receipt as a pass: ampoule-verify prints NOT REPLAYABLE: signature valid, terminated=wall_timeout and verify --replay prints REPLAY N/A, both exit 3. The presence of the key is what counts: any other value, including null, is treated the same way (fail closed). The value is printed only when it is a plain token of 1 to 64 characters from [a-z_] (wall_timeout; null for a JSON null); any other value (the empty string, upper case, spaces, newlines, control characters, objects, numbers) prints as terminated=non-standard, so a signed reason can never add a line or the word VERIFIED to a verifier's output. The verifier library reports such a receipt as not replayable, never as verified. The field is inside the signed statement, so removing or adding it without re-signing breaks the signature. |
Field types (strict parsing)
Verifiers check every field, and reject the receipt (FAILED, exit 1) when one
is missing, null or of the wrong type:
| field | type and allowed values |
|---|---|
version |
integer 1 |
kernel_sha256, payload_sha256, output_sha256 |
string, exactly 64 lowercase hex characters |
vmm |
"qemu" or "firecracker" |
runtime |
"rhai", "wasm", "js" or "python" |
deterministic |
boolean, equal to bit 0x100 of flags |
flags, seed, fuel, status |
integer from 0 to 2^64-1 (no sign, no fraction, no exponent) |
stop |
optional, exactly "fuel", only with status 1 and without terminated; v2 only |
terminated |
optional, any value (its presence is what counts); v2 only |
Unknown extra predicate fields are allowed (in-toto predicates are open) and
ignored. stop is a known v2 field, not an unknown extra one. The statement must have exactly one subject.
Output bytes: cap and UTF-8
output_sha256 covers exactly the bytes the guest captured, and those can
differ from what the program wrote in two ways. Kernels up to and including
the current v2 kernels (60ba9149…) do this without any marker in the output:
- 64 KiB cap. Output is cut at 65,536 bytes (backing off up to 3 bytes to
a UTF-8 boundary). A stop line the kernel appends after the program's own
output (
!! fuel limit,!! <error>, …) is cut with it, so a program that fills the cap can end its output with a fake stop line. Only the signedstatus(andterminated, and on current kernelsstop) say how the run ended; never parse the text for it. - Lossy UTF-8. For Wasm, JavaScript and Python, bytes the program writes that are not valid UTF-8 are replaced with U+FFFD before hashing. Rhai output is always valid UTF-8.
Receipts stay valid and replayable (the guest does the same thing on every
run), so the hardening release flags these cases instead of changing what is
signed: a note is printed when the output is exactly at the cap (a cut of
ASCII text always lands there), or 1 to 3 bytes short of it and contains
multi-byte text (a character straddling the cap is dropped whole), or when
it contains U+FFFD. ASCII output under the cap was not cut and gets no
note. The receipt cannot say for certain whether a cut happened (an output
of exactly 65,536 bytes is flagged even if nothing was dropped, and an ASCII
output left short by a dropped multi-byte character is not); the in-band
truncation marker that settles it is planned for the next predicate version. Every tool prints the same newline-terminated
note (unsigned): ... lines: the MCP server at the end of the result footer,
after the attestation: line (so the output bytes and their digest are
unchanged), the MCP receipt-check tool, and ampoule-verify, from --output or, for
a saved tool result without --output, from the output above the footer
(which it then says it did not check). The notes are not signed.
The verifier also reports output that is not valid UTF-8, which this kernel
never produces. When the MCP receipt-check tool or ampoule-verify is given a whole
tool result, it takes the attestation: line only from the footer (after
the last \n--\nvm:). A guest can print its own attestation: line in its
output, for example a signed envelope copied from another run, and that line
is ignored. An in-band truncation marker and raw
output bytes need a kernel change (new kernel_sha256) and are planned with
the next predicate version.
Envelope and signing
payloadTypeisapplication/vnd.in-toto+json; the signature is Ed25519 over the DSSE pre-authentication encodingDSSEv1 <len(type)> <type> <len(body)> <body>.keyidis the lowercase hex SHA-256 of the raw 32-byte public key. As in DSSE it is an optional hint: absent,nullor empty means "try every trusted key"; a present value restricts that entry to the key it names.- An envelope may carry several signatures. Verifiers try every entry
against every trusted key and accept the envelope when any one verifies
(a bad or foreign signature in front of a good one does not hide it). A
sigthat is not valid standard base64 makes the envelope malformed. - Verification is strict Ed25519 (RFC 8032 with the small-order and
canonical-encoding checks). A weak
trusted key is refused outright (exit 2): any of the 8 small-order
points (orders 1, 2, 4, 8; e.g. the identity,
01followed by 31 zero bytes), under which a signature with R = that point and s = 0 satisfies the lax equation for any statement, and any non-canonical encoding. - Ed25519 is deterministic: the same statement and key give byte-identical envelopes, so two deterministic runs can be compared as envelopes.
- The host only signs when it has re-hashed the payload and output and they match what the guest reported.
What a signature does and does not prove
It proves that the holder of the key vouched for this run, and that nothing
in the statement changed afterwards. It does not prove that the key holder
was honest, or what hardware ran the VM. That is tamper evidence, not remote
attestation. For deterministic runs the signer does not need to be trusted at
all: anyone with the kernel image, the payload and the seed can re-execute and
compare output digest and fuel (verify --replay). Replay needs the exact
kernel named by kernel_sha256: receipts produced before Rhai fuel was
recorded (older kernel hash) only replay against their own kernel. Kernels
before terminated was added leave the header word zero, so their receipts
never carry the field; the kernel that sets it has a new kernel_sha256.
Statements and envelopes are parsed strictly: a duplicate JSON key at any level (for example terminated or predicateType given twice) makes the receipt FAILED, since the signed bytes must have exactly one meaning.
A receipt with terminated present is not replayable (see the field table). Binding the signing key to
a measured kernel through a TEE is future work.
Trust boundary
A signature proves which host key signed a receipt; it does not prove the host told the truth. Anyone holding the signing key can produce any receipt, including one that drops terminated from a run the host killed.
- Deterministic runs: replay is the independent check.
verify --replaywith the exact kernel image re-derives the output and fuel; a receipt that does not match is rejected (REPLAY FAILED: <field> differs from the receipt, exit 1), as is one whose signature does not verify with the given key (REPLAY FAILED: signature not valid for this key, checked before anything is re-run). Only exit 0 printsREPLAY OK. Replay detects tampering only when the rerun's output or fuel differs from the receipt (in a downgrade test the output matched and only the fuel differed). Replay checks deterministic runs only, and only when the run ended by fuel, not by the host wall clock; a rerun that itself hits the wall clock is reported asREPLAY FAILED: rerun hit wall timeout. - Non-deterministic runs: they cannot be replayed, so the signing key is the entire trust boundary. Protect the key, rotate it, and revoke it on compromise; consumers that log accepted receipts by key can re-flag past acceptances after a revocation.
v2: differences from v1
v2 exists so that a verifier that only knows v1 fails closed. A v1-only
verifier checks the signature and the predicate type, and would print
VERIFIED for a receipt whose run the host stopped (terminated), because v1
never defined that field. Under v2 the same verifier rejects every new
receipt for its unknown predicate type instead.
- Predicate type is
https://ampoule.dev/attestation/run/v2. Every receipt signed from v2 on uses it, terminated or not, so the predicate type alone never tells a reader whether the run ended normally. terminated(optional) is defined by v2 only; see the field table. A v2 verifier must report a receipt carrying it as not replayable, never as a pass (ampoule-verifyandverify --replayexit 3).stop(optional) was added to v2 later, without a new predicate type, because it cannot change any earlier verifier's verdict: it is only ever written withstatus1, so a verifier that ignores it (any v2 verifier released before the field was added) still reports RUN FAILED, exit 4, never a pass. It is rejected in a v1 statement, liketerminated.- All other fields (
versionstays1,kernel_sha256,vmm,runtime,flags,deterministic,seed,payload_sha256,output_sha256,fuel,status), the subject, the statement and the DSSE envelope are unchanged.
What a v2 verifier accepts:
| predicateType | terminated absent |
terminated present |
|---|---|---|
.../run/v2 |
accepted (VERIFIED, exit 0 with the kernel pinned, INCOMPLETE, exit 5, without; RUN FAILED, exit 4, when status ≠ 0) |
signature checked, NOT REPLAYABLE (exit 3) |
.../run/v1 |
accepted (legacy receipt, exit 0 or 5 as for v2; exit 4 when status ≠ 0) |
rejected (FAILED, exit 1): v1 never defined the field |
| anything else | rejected (exit 1) | rejected (exit 1) |
stop: "fuel" (v2, terminated absent, status 1) is RUN FAILED, exit 4,
like the same receipt without it; in a v1 statement it is rejected (exit 1).
Exit-code change in the hardening release
Deliberate: a receipt whose signed status is nonzero (and that has no
terminated) used to exit 0 (VERIFIED) and now exits 4
(RUN FAILED: signature valid, status N, meaning (<scope>)). Nothing else changes exit code
for a receipt that was valid before; receipts that violate the field types
above, or lack kernel_sha256, now fail (exit 1), and a missing or
unreadable --output/--payload/--kernel file exits 2 instead of 1:
an I/O problem says nothing about the receipt, so it is a usage/I-O error
like an unreadable ENVELOPE (which was already 2), not FAILED. Every file
is read before any verdict, so 2 outranks 1. Callers that treat
"exit 0 or 3" as "the signature is fine" should add 4. Also new: exit 6
for an input over the 64 MiB cap (nothing verified), verify --replay
exits 4 (REPLAY MATCHED, RUN FAILED) instead of 0 for a matching replay
of a receipt with a nonzero status, weak (small-order) keys are refused
(exit 2), and an attestation: line in text without the MCP footer is
ignored unless it is the whole text.
Also deliberate: exit 5, INCOMPLETE. A good receipt (signature valid,
status 0, no terminated, every given digest matching) whose kernel was not
pinned (neither --kernel nor --expected-kernel-sha256) used to exit 0
and now exits 5. Its result line leads with INCOMPLETE and never contains
the word VERIFIED, so a caller that greps for VERIFIED cannot mistake it
for a pass. It ends with the same scope suffix as NOT REPLAYABLE and RUN
FAILED, then how to pin:
result INCOMPLETE: signature valid, kernel not pinned (payload checked; output, kernel not checked); pin it with --kernel or --expected-kernel-sha256
The suffix is (<checked> checked; <unchecked> not checked), or
(signature only; <unchecked> not checked) when no file was given; the
unchecked list is always complete, in the order output, payload, kernel.
Exit 0 means a fully pinned check and its line starts with VERIFIED.
The same rule holds for every non-pass verdict: the word VERIFIED appears
only in exit 0's line. Verdicts lead with their own word, then signature
valid where the signature checked out:
result NOT REPLAYABLE: signature valid, terminated=wall_timeout (signature only; output, payload, kernel not checked)
result RUN FAILED: signature valid, status 1, program error (payload checked; output, kernel not checked)
result INCOMPLETE: signature valid, kernel not pinned (signature only; output, payload, kernel not checked); pin it with --kernel or --expected-kernel-sha256
result FAILED
The MCP server's receipt-check tool never checks the kernel, so it answers
INCOMPLETE: signature valid, kernel not checked by this tool (<scope>) for
a good receipt, and NOT REPLAYABLE: … / RUN FAILED: … in the same form.
Callers that only want the signature should accept 5 as well.
Precedence when several apply: 2 (and 6, both decided before any verdict) > 1 FAILED > 3 NOT REPLAYABLE > 4 RUN FAILED > 5 INCOMPLETE > 0 VERIFIED. So an unpinned check of a failed run is 4, and an unpinned not-replayable run is 3.
Verifying
ampoule-verify envelope.json (--key <pubkey-hex|file> | --trusted-keys FILE)... \
[--output F] [--payload F] [--kernel F] [--expected-kernel-sha256 HEX]
verify --replay envelope.json --key <pubkey> --js code.js
The public key is the trust anchor and must reach the verifier by some other
channel than the envelope. ampoule-verify pubkey prints the key a host signs with.