Protocole de plugin de Continuum Attest attest-plugin/1
Statut : projet de spécification. La version 0.1.0 n’intègre pas d’hôte de plugins et n’exécute aucun plugin. Ce document propose un protocole d’extension ; ses garanties sont des exigences pour une implémentation future, et non des capacités de la version actuelle.
Ce document définit comment attest délègue des tâches à des plugins
externes. Il constitue le contrat à implémenter par les tiers et délimite
les responsabilités du cœur open source et des extensions propriétaires.
Les termes DOIT, NE DOIT PAS, DEVRAIT et PEUT correspondent respectivement à MUST, MUST NOT, SHOULD et MAY au sens de la RFC 2119.
1. Périmètre
Le cœur produit et vérifie les reçus : calcul des empreintes des entrées et sorties déclarées, exécution des étapes, enregistrement du journal causal, signature et vérification. Les plugins complètent l’exécution par des décisions et des observations que le cœur ne réalise pas lui-même : gouvernance des politiques, réconciliation des clusters, télémétrie ou ancrage externe.
Fonctions exclues
Ce protocole ne permet délibérément pas à un plugin :
- de signer quoi que ce soit ou de lire des données de clé privée ;
- de modifier un fait enregistré : empreinte d’entrée ou de sortie, code de sortie, durée ou horodatage ;
- de devenir indispensable à la vérification d’un reçu.
Ces exclusions font partie de la conception. Elles définissent les conditions d’utilisation d’un plugin propriétaire dans un outil destiné à produire des éléments de preuve.
2. Garanties
Une implémentation conforme du cœur DOIT respecter ces cinq garanties.
G1 — Indépendance de la vérification. attest verify DOIT rendre son
verdict sans exécuter de plugin. Un reçu produit sur une machine dotée de
plugins DOIT donner le même résultat de vérification sur une machine qui
n’en possède aucun. Un plugin PEUT ajouter des contrôles à une vérification ;
il NE DOIT PAS pouvoir transformer un échec en succès, ni être nécessaire
au verdict de base.
G2 — Aucune signature par les plugins. Aucune clé privée, aucun chemin vers une clé et aucun oracle de signature ne traverse cette interface. La signature est réalisée dans le cœur, après consultation de tous les plugins et fixation de leurs enregistrements.
G3 — Aucune modification des faits. La réponse d’un plugin peut contenir une décision, des constats et une annotation placée dans son propre espace de noms. Elle ne peut contenir aucun élément qui remplace une valeur mesurée. Le cœur DOIT ignorer tout champ d’une réponse de plugin qui n’est pas défini par cette spécification.
G4 — Participation enregistrée. Tout plugin consulté pendant une exécution DOIT figurer dans le reçu avec son nom, sa version, la version du protocole, l’empreinte de son binaire et sa décision, y compris en cas d’échec, de dépassement du délai ou d’exécution ignorée. Sans cet enregistrement, une exécution où un plugin n’a silencieusement rien fait serait impossible à distinguer d’une exécution sans plugin. Une telle ambiguïté est incompatible avec un document de preuve.
G5 — Le déterminisme est déclaré. Un plugin indique si sa sortie est une
fonction pure de son entrée. Une sortie non déterministe est enregistrée,
mais exclue de la comparaison de reproductibilité ;
--check-reproducibility signale cette exclusion.
3. Découverte et confiance
attest NE DOIT PAS exécuter un binaire au seul motif que son nom commence
par attest- et qu’il se trouve dans PATH. Les plugins sont déclarés,
associés à une empreinte attendue, puis vérifiés avant leur lancement.
3.1 Déclaration
Les plugins sont déclarés dans .attest/plugins.toml, versionné dans
le dépôt avec le magasin de confiance.
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 30Déclarer un point d’extension (hook) que le plugin n’annonce pas lors de la négociation initiale constitue une erreur de configuration (code de sortie 2). Si un plugin annonce un hook qui n’a pas été déclaré, le cœur ne lui adresse jamais de requête pour ce hook.
3.2 Vérification avant lancement
Avant chaque lancement, le cœur calcule l’empreinte BLAKE3 du binaire
résolu et la compare à digest. En cas de différence, l’exécution échoue
avec le code de sortie 2 et le message indique les deux empreintes.
Aucune option ne permet de contourner ce contrôle : un outil qui fixe
les empreintes des images de capsules doit contrôler avec la même rigueur
les binaires qu’il exécute lui-même.
La résolution de command NE DOIT PAS rechercher de binaire dans le
répertoire courant. Les chemins relatifs autres qu’un simple nom de
commande sont rejetés.
3.3 Commandes du CLI
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
- Un processus par plugin et par invocation d’
attest, et non par étape. - Le cœur écrit les requêtes sur l’entrée standard (stdin) du plugin et lit ses réponses sur sa sortie standard (stdout).
- Les messages utilisent JSON Lines : un objet JSON compact encodé
en UTF-8 par ligne, terminé par un seul
\n. Pas de mise en forme indentée ni de sauts de ligne internes. - stdout contient uniquement les messages du protocole. Toute ligne non JSON constitue une violation du protocole. Les plugins DOIVENT écrire les diagnostics sur stderr, que le cœur collecte dans le journal d’exécution sans jamais les interpréter.
- Un message NE DOIT PAS dépasser 1 Mio. Un message plus volumineux
constitue une violation du protocole. Cette limite est identique à
celle du mécanisme de transmission des reçus dans
src/sync.rs.
gRPC a été étudié puis écarté : il impose une chaîne d’outils protobuf,
un port ou un socket et une gestion de TLS pour des échanges courts,
strictement organisés en requêtes et réponses. JSON Lines sur les flux
standard peut être implémenté dans n’importe quel langage, y compris
dans un script shell avec jq, ce qui facilite l’adoption.
4.1 Environnement
Le processus du plugin est lancé avec un environnement construit explicitement. Le cœur NE DOIT PAS lui transmettre son propre environnement tel quel. Les variables suivantes sont définies :
| Variable | Valeur |
|---|---|
ATTEST_PLUGIN_PROTOCOL |
Version majeure du protocole, par exemple 1 |
ATTEST_WORKSPACE |
Chemin absolu de la racine de l’espace de travail |
PATH, HOME, TZ, LC_ALL, LANG |
Héritées ou normalisées comme pour les étapes hermétiques |
Toute variable supplémentaire nécessaire au plugin DOIT être déclarée
dans une table env du fichier .attest/plugins.toml. Ainsi, les données
transmises à un binaire propriétaire sont visibles dans le dépôt.
Le répertoire de travail est ATTEST_WORKSPACE.
5. Cycle de vie
spawn ──► hello ──► hello_ok ──► [ request ──► response ]* ──► shutdown ──► exit 0
- Le cœur lance le plugin et envoie
hello. - Le plugin répond
hello_okdans le délaitimeout_secs. Toute autre réponse, ou l’annonce d’un protocole incompatible, constitue un échec de démarrage. - Le cœur envoie les requêtes des hooks dans l’ordre, au fil de l’exécution. Les requêtes sont strictement séquentielles : il attend la réponse à une requête avant d’envoyer la suivante. Un plugin NE DOIT PAS écrire de ligne non sollicitée.
- Le cœur envoie
shutdown. Le plugin vide ses tampons, ferme stdout et se termine avec le code 0. - Si le plugin ne s’est pas arrêté dans les 5 secondes suivant
shutdown, le cœur envoieSIGTERM, puisSIGKILLaprès 5 secondes supplémentaires.
6. Messages
Chaque message est un objet contenant v (version majeure du protocole,
entier) et kind (chaîne de caractères). Chaque requête porte un id
entier strictement croissant, à partir de 1. Chaque réponse reprend l’id
de la requête à laquelle elle répond.
Le destinataire DOIT ignorer les champs inconnus d’un message. Cette règle est l’inverse de celle du format de reçu, qui rejette les champs inconnus : un protocole d’échange doit pouvoir évoluer, tandis qu’un artefact signé doit rester stable.
6.1 hello (cœur → plugin)
{"v":1,"id":1,"kind":"hello","attest_version":"1.0.0","protocols":[1]}6.2 hello_ok (plugin → cœur)
{"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 DOIT correspondre au nom déclaré. deterministic DOIT correspondre
à la valeur déclarée ; une différence constitue une erreur de configuration,
et non un remplacement silencieux.
6.3 Requêtes des hooks (cœur → plugin)
kind |
Moment d’envoi | Données transmises |
|---|---|---|
pipeline.validate |
Une fois, avant la première étape | pipeline_hash, pipeline (définition analysée) |
step.pre |
Avant chaque étape | step (name, run, inputs, outputs, needs, env, image, cache, timeout_secs, attestation) |
step.post |
Après chaque étape | step_result (name, input_hash, output_hash, duration_secs, exit_code, cache_hit, capsule_hash) |
receipt.finalize |
Une fois, avant la signature | receipt (reçu complet avec signature et signer_public_key à null, et sans plugins) |
Les données de step.post contiennent les faits enregistrés pour l’étape.
Elles ne contiennent pas stdout et stderr par défaut. Un plugin qui en
a besoin déclare wants_output = true et reçoit ces flux, tronqués à
64 Kio chacun.
6.4 verdict (plugin → cœur)
{"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. Undenysurpipeline.validateoustep.prearrête l’exécution avant que le travail ne soit effectué. Undenysurstep.postoureceipt.finalizefait échouer l’exécution après ce travail ; le reçu est tout de même écrit et consigne le refus.findings— 256 entrées au maximum.severityvautinfo|low|medium|high|critical.annotation— objet JSON libre, limité à 64 Kio une fois sérialisé, enregistré dans le reçu sous le nom du plugin. Le cœur le traite comme une donnée opaque.
6.5 shutdown (cœur → plugin)
{"v":1,"id":42,"kind":"shutdown"}7. Intégration au reçu
Un nouveau champ facultatif est ajouté au reçu à la version 3 du schéma :
/// 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 DOIT trier plugins par name, en complément du
tri existant de steps et de causal_events, pour obtenir des données
signées canoniques.
RECEIPT_SCHEMA_VERSION passe de 2 à 3. Le contrôle de version dans
src/verify.rs lit déjà schema_version dans le document brut avant
l’analyse stricte. Un ancien binaire qui rencontre un reçu de version 3
affiche donc un message demandant de mettre à jour attest pour vérifier
ce reçu, plutôt qu’une erreur obscure de champ inconnu. Ce mécanisme
ne nécessite aucune autre modification.
Les reçus de version 2 ou antérieure restent vérifiables sans modification.
8. Gestion des échecs
| Situation | fail-closed (par défaut) |
fail-open |
|---|---|---|
| Échec du lancement ou empreinte différente | Échec de l’exécution, code 2 | Échec de l’exécution, code 2 — jamais toléré |
| Échec ou dépassement du délai de négociation initiale | Échec de l’exécution, code 1 | Enregistrer error, puis continuer |
| Dépassement du délai d’une requête | Échec de l’exécution, code 1 | Enregistrer error, puis continuer |
| Violation du protocole | Échec de l’exécution, code 1 | Enregistrer error, puis continuer |
| Arrêt du plugin pendant l’exécution | Échec de l’exécution, code 1 | Enregistrer error, puis continuer |
En mode fail-open, le PluginRecord du plugin est tout de même écrit,
avec decision: "error" et un champ reason. La poursuite après échec
est enregistrée, jamais silencieuse. Sinon, un reçu pourrait prétendre
qu’une politique a été contrôlée alors qu’elle ne l’a pas été.
Une différence d’empreinte n’est tolérée dans aucun des deux modes : le binaire présent sur disque n’est pas celui qui a été examiné.
9. Déterminisme et reproductibilité
attest run --check-reproducibility exécute deux fois le pipeline et compare
les empreintes des sorties. Les annotations des plugins font partie du reçu
et peuvent donc faire partie des éléments comparés par un destinataire.
deterministic = true—decisionetannotationDOIVENT être une fonction pure des données de la requête. Elles participent à la comparaison ; une différence entre les deux exécutions constitue un échec de reproductibilité attribué au plugin par son nom.deterministic = false— lePluginRecordest toujours écrit, maisdecisionetannotationsont exclues de la comparaison. Le rapport de reproductibilité nomme chaque plugin exclu et en indique la raison.
Un plugin qui consulte l’horloge, le réseau ou un stockage externe
modifiable n’est pas déterministe et DOIT le déclarer. Déclarer true
tout en ayant un comportement non déterministe provoque un échec de
reproductibilité dû au plugin, ce qui est le résultat attendu.
10. Codes de sortie
Les plugins suivent la convention du CLI :
| Code | Signification |
|---|---|
| 0 | Arrêt normal |
| 1 | Échec propre au plugin : dépendance indisponible, configuration incorrecte |
| 2 | Erreur de protocole ou erreur opérationnelle |
11. Gestion des versions
Le protocole est identifié par une version majeure : attest-plugin/1.
Le cœur annonce les versions majeures prises en charge dans hello ;
le plugin en sélectionne une dans hello_ok. Au sein d’une version
majeure, seuls les ajouts sont autorisés : nouveaux champs facultatifs,
nouvelles valeurs de kind que le destinataire peut ignorer, nouveaux
hooks qu’un plugin peut choisir de ne pas annoncer.
Les types des messages sont publiés dans le paquet Rust
attest-plugin-protocol, sous Apache-2.0, avec un versionnement sémantique
(semver). Dépendre de ce paquet n’impose pas de dépendre du cœur :
un plugin a besoin du protocole, pas de son implémentation.
12. Considérations de sécurité
Exécuter un plugin revient à exécuter du code arbitraire avec les privilèges de l’utilisateur. Le contrôle repose sur l’empreinte fixée dans un fichier versionné : le code exécuté est celui qui a été examiné, et toute modification apparaît dans l’historique du dépôt.
- Un plugin hostile peut refuser chaque étape. Il ne peut pas fabriquer un faux reçu, signer ou modifier une empreinte mesurée. Le déni de service fait partie des risques ; la falsification est exclue.
- Du point de vue du cœur, les annotations peuvent être contrôlées par un attaquant. Le cœur les stocke comme des données opaques et NE DOIT PAS les interpréter. Les outils qui affichent un reçu DOIVENT traiter leur contenu comme du texte non fiable.
- Les chaînes
resourceetrulesont limitées à 256 octets et ne constituent pas des chemins. Le cœur NE DOIT PAS les utiliser pour ouvrir des fichiers. - Le plugin ne reçoit jamais
.attest/keys/, l’identifiant de la clé de signature ni l’environnement du cœur. receipt.finalizereçoit le reçu avant sa signature. Il ne voit aucune signature, et son propre enregistrement est ajouté après sa réponse : un plugin ne peut ni observer ni influencer ce qui est enregistré à son sujet.
13. Référence : un plugin minimal
Voici un plugin conforme écrit en shell POSIX pour tester le 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. Questions ouvertes
Points à résoudre avant de déclarer le protocole normatif :
- Hooks pendant la vérification. Faut-il créer un hook
receipt.checkpermettant à un plugin d’ajouter des contrôles pendantattest verify? G1 n’autorise qu’un contrôle supplémentaire, incapable de transformer un échec en succès. Le risque est que les opérateurs comprennent « vérifié » comme « vérifié avec le plugin ». L’orientation actuelle est de reporter ce hook àattest-plugin/2et de livrer la version 1.0 avec les seuls hooks du côté producteur. - Étapes parallèles. Les requêtes sont actuellement séquentielles,
ce qui sérialise
step.preetstep.postdans un graphe de dépendances (DAG) exécuté en parallèle. Les options envisagées sont une fenêtre de concurrence par étape ou un processus de plugin par processus de travail. Des mesures sont nécessaires avant de choisir une conception. - Empreintes selon la plateforme. Une seule valeur
digestpar plugin suppose un seul binaire. Les plugins multiplateformes nécessitent une correspondance entre chaque cible et son empreinte, probablement sous la forme d’une table[[plugin.target]]indexée parosetarch.