Continuum Attest plugin protocol attest-plugin/1
Status: draft. The 0.1.0 release does not implement a plugin host and does not execute plugins. This document describes a proposed extension protocol; its guarantees are requirements for a future implementation, not capabilities of the current release.
This document specifies how attest delegates work to external plugins. It is
the contract third parties implement, and the boundary between the open-source
core and any proprietary extension.
Keywords MUST, MUST NOT, SHOULD, MAY are to be read as in RFC 2119.
1. Scope
The core is responsible for producing and verifying receipts: hashing declared inputs and outputs, running steps, recording the causal ledger, signing, and verifying. Plugins extend the run with decisions and observations the core does not make on its own — policy governance, cluster reconciliation, telemetry, external anchoring.
Non-goals
This protocol deliberately does not let a plugin:
- sign anything, or read private key material;
- alter a recorded fact (input hash, output hash, exit code, duration, timestamp);
- become necessary to verify a receipt.
These are not omissions to be fixed later. They are what makes it safe to run a closed-source plugin inside a tool whose purpose is evidence.
2. Guarantees
A conforming implementation of the core MUST uphold all five.
G1 — Verification independence. attest verify MUST reach its verdict
without executing any plugin. A receipt produced on a machine with plugins MUST
verify identically on a machine with none. A plugin MAY add checks to a
verification run; it MUST NOT be able to turn a failing receipt into a passing
one, nor be required for the base verdict.
G2 — No signing. No private key, no key path, and no signing oracle crosses the boundary. Signing happens in the core, after every plugin has been consulted and its record fixed.
G3 — No fact mutation. A plugin's response can carry a decision, findings, and a namespaced annotation. It can carry nothing that overwrites a measured value. The core MUST ignore any field in a plugin response that is not defined by this specification.
G4 — Recorded participation. Every plugin consulted during a run MUST appear in the receipt with its name, version, protocol version, binary digest, and decision — including when it failed, timed out, or was skipped. A run where a plugin silently did nothing is indistinguishable from a run with no plugin, and that ambiguity is not acceptable in an evidence document.
G5 — Determinism is declared, not assumed. A plugin declares whether its
output is a pure function of its input. Non-deterministic output is recorded but
excluded from reproducibility comparison, and --check-reproducibility reports
the exclusion.
3. Discovery and trust
attest MUST NOT execute a binary merely because it is named attest-* and sits
on PATH. Plugins are declared, pinned by digest, and verified before spawn.
3.1 Declaration
Plugins are declared in .attest/plugins.toml, committed to the repository
alongside the trust store.
schema_version = 1
[[plugin]]
name = "policy" # [a-z0-9-]{1,32}; also the annotation namespace
command = "attest-policy" # resolved on PATH, or an absolute path
digest = "blake3:9f2c...e1" # of the binary, verified before every spawn
protocol = "1" # required protocol major version
hooks = ["pipeline.validate", "step.pre", "receipt.finalize"]
deterministic = true
on_failure = "fail-closed" # fail-closed (default) | fail-open
timeout_secs = 30 # per request; default 30Declaring a hook the plugin does not announce at handshake is a configuration error (exit 2). Announcing a hook that was not declared means the core never sends it.
3.2 Verification before spawn
Before each spawn the core computes the BLAKE3 digest of the resolved binary and
compares it to digest. On mismatch the run fails with exit code 2 and the
message names both digests. There is no override flag: a tool that pins image
digests for capsules does not get to be lax about the binaries it executes
itself.
command resolution MUST NOT search the current directory. Relative paths other
than a bare command name are rejected.
3.3 CLI surface
attest plugins list # declared plugins, resolution, digest status
attest plugins verify # re-verify every declared digest; 0/1/2
attest plugins pin <name> # recompute and write the digest after an upgrade
4. Transport
- One process per plugin per
attestinvocation, not one per step. - Requests are written by the core to the plugin's stdin; responses are read from its stdout.
- Framing is JSON Lines: one compact UTF-8 JSON object per line, terminated
by a single
\n. No pretty-printing, no embedded newlines. - stdout carries protocol traffic and nothing else. A non-JSON line on stdout is a protocol violation. Plugins MUST write diagnostics to stderr, which the core captures into the run log and never parses.
- A message MUST NOT exceed 1 MiB. Larger messages are a protocol violation.
(Same bound as the receipt delivery path in
src/sync.rs.)
gRPC was considered and rejected: it requires a protobuf toolchain, a port or
socket, and a TLS story, for interactions that are small and strictly
request/response. JSON Lines over stdio is implementable in any language,
including a shell script with jq, which matters for adoption.
4.1 Environment
The plugin process is spawned with an explicitly constructed environment. The core MUST NOT pass its own environment through. The following are set:
| Variable | Value |
|---|---|
ATTEST_PLUGIN_PROTOCOL |
major protocol version, e.g. 1 |
ATTEST_WORKSPACE |
absolute path to the workspace root |
PATH, HOME, TZ, LC_ALL, LANG |
inherited or normalized as for hermetic steps |
Any further variable a plugin needs MUST be declared in .attest/plugins.toml
under an env table, so that what reaches a closed-source binary is visible in
the repository. The working directory is ATTEST_WORKSPACE.
5. Lifecycle
spawn ──► hello ──► hello_ok ──► [ request ──► response ]* ──► shutdown ──► exit 0
- The core spawns the plugin and sends
hello. - The plugin answers
hello_okwithintimeout_secs. A plugin that answers anything else, or announces an incompatible protocol, is a startup failure. - The core sends hook requests as the run progresses, in order. Requests are strictly sequential: the core sends one request and waits for its response before sending the next. A plugin MUST NOT write unsolicited lines.
- The core sends
shutdown. The plugin flushes, closes stdout, and exits 0. - If the plugin has not exited within 5 seconds of
shutdown, the core sendsSIGTERM, thenSIGKILLafter a further 5 seconds.
6. Messages
Every message is an object with v (protocol major, integer) and kind
(string). Every request carries id (monotonically increasing integer starting
at 1); every response echoes the id it answers.
Unknown fields in a message MUST be ignored by the receiver. This is the opposite of the receipt format, which rejects unknown fields — a wire protocol must evolve, a signed artifact must not drift.
6.1 hello (core → plugin)
{"v":1,"id":1,"kind":"hello","attest_version":"1.0.0","protocols":[1]}6.2 hello_ok (plugin → core)
{"v":1,"id":1,"kind":"hello_ok","name":"policy","version":"1.4.2","protocol":1,
"hooks":["pipeline.validate","step.pre","receipt.finalize"],"deterministic":true}name MUST equal the declared name. deterministic MUST equal the declared
value; a mismatch is a configuration error, not a silent override.
6.3 Hook requests (core → plugin)
kind |
Sent | Payload |
|---|---|---|
pipeline.validate |
once, before the first step | pipeline_hash, pipeline (parsed definition) |
step.pre |
before each step | step (name, run, inputs, outputs, needs, env, image, cache, timeout_secs, attestation) |
step.post |
after each step | step_result (name, input_hash, output_hash, duration_secs, exit_code, cache_hit, capsule_hash) |
receipt.finalize |
once, before signing | receipt (the complete receipt with signature and signer_public_key null, and plugins absent) |
step.post payloads carry the step's recorded facts. They do not carry
stdout/stderr by default; a plugin that needs them declares
wants_output = true and receives them truncated to 64 KiB per stream.
6.4 verdict (plugin → core)
{"v":1,"id":7,"kind":"verdict","decision":"allow",
"findings":[
{"rule":"slsa-build-platform","severity":"high",
"message":"Build must run on a hosted platform (SLSA L2)","resource":"step:build"}
],
"annotation":{"slsa_level":2,"controls_evaluated":14}}decision—allow|warn|deny.denyonpipeline.validateorstep.prestops the run before the work happens.denyonstep.postorreceipt.finalizefails the run after it; the receipt is still written and records the denial.findings— at most 256 entries.severityisinfo|low|medium|high|critical.annotation— an arbitrary JSON object, at most 64 KiB serialized, stored in the receipt under the plugin's name. The core treats it as opaque.
6.5 shutdown (core → plugin)
{"v":1,"id":42,"kind":"shutdown"}7. Receipt integration
A new optional field is added to the receipt at schema version 3:
/// Plugins consulted during this run, sorted by name. Absent when no
/// plugin was declared, so receipts from plugin-free runs and their
/// signatures stay byte-identical (same pattern as `capsule_hash`,
/// `reproducibility` and `provenance`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugins: Option<Vec<PluginRecord>>,#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginRecord {
pub name: String,
pub version: String,
pub protocol: u32,
/// blake3 of the plugin binary, as verified before spawn.
pub digest: String,
/// allow | warn | deny | error | skipped
pub decision: String,
pub deterministic: bool,
/// Present only when the plugin failed, timed out, or was skipped.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub annotation: Option<serde_json::Value>,
}receipt_signing_bytes MUST sort plugins by name, alongside the existing
sort of steps and causal_events, so the signed payload is canonical.
RECEIPT_SCHEMA_VERSION goes from 2 to 3. The version gate in src/verify.rs
already reads schema_version from the raw document before the strict parse, so
an older binary meeting a version 3 receipt reports "upgrade attest to verify
this receipt" rather than an opaque unknown-field error. No further work is
needed for that path.
Receipts at version 2 and below remain verifiable unchanged.
8. Failure modes
| Situation | fail-closed (default) |
fail-open |
|---|---|---|
| Spawn fails, digest mismatch | run fails, exit 2 | run fails, exit 2 — never tolerated |
| Handshake fails or times out | run fails, exit 1 | record error, continue |
| Request times out | run fails, exit 1 | record error, continue |
| Protocol violation | run fails, exit 1 | record error, continue |
| Plugin exits mid-run | run fails, exit 1 | record error, continue |
Under fail-open the plugin's PluginRecord is still written, with
decision: "error" and a reason. Failing open is recorded, never silent —
otherwise a receipt could claim policy coverage that never ran.
A digest mismatch is never tolerated under either mode: the binary on disk is not the binary that was reviewed.
9. Determinism and reproducibility
attest run --check-reproducibility runs the pipeline twice and compares output
hashes. Plugin annotations are part of the receipt and therefore part of what a
consumer might compare.
deterministic = true— the plugin'sdecisionandannotationMUST be a pure function of the request payload. They participate in the comparison; a difference between the two runs is a reproducibility failure attributed to the plugin by name.deterministic = false— thePluginRecordis still written, butdecisionandannotationare excluded from the comparison, and the reproducibility report names every plugin excluded and why.
A plugin that reads the clock, the network, or a mutable external store is not
deterministic and MUST declare so. Declaring true while behaving otherwise
manifests as a spurious reproducibility failure, which is the intended outcome.
10. Exit codes
Plugins follow the CLI's convention:
| Code | Meaning |
|---|---|
| 0 | clean shutdown |
| 1 | plugin-level failure (its own dependency unavailable, bad configuration) |
| 2 | protocol or operational error |
11. Versioning
The protocol is identified by a major version: attest-plugin/1. The core
advertises the majors it supports in hello; the plugin selects one in
hello_ok. Within a major version, only additive changes are permitted — new
optional fields, new kind values a receiver may ignore, new hooks a plugin may
decline to announce.
The wire types are published as the attest-plugin-protocol crate under
Apache-2.0, versioned with semver. Depending on that crate does not require
depending on the core: a plugin needs the protocol, not the implementation.
12. Security considerations
Executing a plugin is executing arbitrary code with the user's privileges. Digest pinning in a committed file is the control: what runs is what was reviewed, and a change to it is a diff in version control.
- A hostile plugin can deny every step. It cannot forge a receipt, sign, or change a measured hash. Denial of service is in scope; forgery is not.
- Annotations are attacker-controlled data from the core's perspective. The core stores them opaquely and MUST NOT interpret them. Consumers rendering a receipt MUST treat annotation content as untrusted text.
resourceandrulestrings are bounded (256 bytes) and are not paths. The core MUST NOT use them to open files.- The plugin never receives
.attest/keys/, the signing key id, or the core's environment. receipt.finalizesees the receipt before signing. It sees no signature, and its own record is added after it has answered — a plugin cannot observe or influence what is recorded about itself.
13. Reference: a minimal plugin
A conforming plugin in POSIX shell, for testing the transport:
#!/bin/sh
# attest-noop — allows everything, announces one hook.
while IFS= read -r line; do
kind=$(printf '%s' "$line" | jq -r .kind)
id=$(printf '%s' "$line" | jq -r .id)
case "$kind" in
hello)
printf '{"v":1,"id":%s,"kind":"hello_ok","name":"noop","version":"0.1.0",' "$id"
printf '"protocol":1,"hooks":["step.pre"],"deterministic":true}\n'
;;
shutdown) exit 0 ;;
*) printf '{"v":1,"id":%s,"kind":"verdict","decision":"allow","findings":[]}\n' "$id" ;;
esac
done14. Open questions
Tracked before the protocol is declared normative:
- Verification-time hooks. Should a
receipt.checkhook exist, letting a plugin add checks duringattest verify? G1 permits it only as an additive check that cannot rescue a failing receipt. The risk is that operators come to read "verified" as "verified with the plugin". Current inclination: defer toattest-plugin/2, and ship 1.0 with the producer-side hooks only. - Parallel steps. Requests are sequential today, which serializes
step.pre/step.postagainst a parallel DAG. Options: a per-step concurrency window, or one plugin process per worker. Needs measurement before it is designed. - Digest pinning across platforms. One
digestper plugin assumes one binary. Multi-platform plugins need a per-target digest map. Likely a[[plugin.target]]table keyed byos/arch.