BenefitsCLI docsRelease 0.1Get started
CLI documentation

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 30

Declaring 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 attest invocation, 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
  1. The core spawns the plugin and sends hello.
  2. The plugin answers hello_ok within timeout_secs. A plugin that answers anything else, or announces an incompatible protocol, is a startup failure.
  3. 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.
  4. The core sends shutdown. The plugin flushes, closes stdout, and exits 0.
  5. If the plugin has not exited within 5 seconds of shutdown, the core sends SIGTERM, then SIGKILL after 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}}
  • decisionallow | warn | deny. deny on pipeline.validate or step.pre stops the run before the work happens. deny on step.post or receipt.finalize fails the run after it; the receipt is still written and records the denial.
  • findings — at most 256 entries. severity is info | 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's decision and annotation MUST 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 — the PluginRecord is still written, but decision and annotation are 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.
  • resource and rule strings 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.finalize sees 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
done

14. Open questions

Tracked before the protocol is declared normative:

  1. Verification-time hooks. Should a receipt.check hook exist, letting a plugin add checks during attest 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 to attest-plugin/2, and ship 1.0 with the producer-side hooks only.
  2. Parallel steps. Requests are sequential today, which serializes step.pre/step.post against a parallel DAG. Options: a per-step concurrency window, or one plugin process per worker. Needs measurement before it is designed.
  3. Digest pinning across platforms. One digest per plugin assumes one binary. Multi-platform plugins need a per-target digest map. Likely a [[plugin.target]] table keyed by os/arch.