BénéficesDoc CLIVersion 0.1Démarrer
Documentation CLI

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 30

Dé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
  1. Le cœur lance le plugin et envoie hello.
  2. Le plugin répond hello_ok dans le délai timeout_secs. Toute autre réponse, ou l’annonce d’un protocole incompatible, constitue un échec de démarrage.
  3. 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.
  4. Le cœur envoie shutdown. Le plugin vide ses tampons, ferme stdout et se termine avec le code 0.
  5. Si le plugin ne s’est pas arrêté dans les 5 secondes suivant shutdown, le cœur envoie SIGTERM, puis SIGKILL aprè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}}
  • decisionallow | warn | deny. Un deny sur pipeline.validate ou step.pre arrête l’exécution avant que le travail ne soit effectué. Un deny sur step.post ou receipt.finalize fait é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. severity vaut info | 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 = truedecision et annotation DOIVENT ê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 — le PluginRecord est toujours écrit, mais decision et annotation sont 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 resource et rule sont 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.finalize reç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
done

14. Questions ouvertes

Points à résoudre avant de déclarer le protocole normatif :

  1. Hooks pendant la vérification. Faut-il créer un hook receipt.check permettant à un plugin d’ajouter des contrôles pendant attest 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/2 et de livrer la version 1.0 avec les seuls hooks du côté producteur.
  2. Étapes parallèles. Les requêtes sont actuellement séquentielles, ce qui sérialise step.pre et step.post dans 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.
  3. Empreintes selon la plateforme. Une seule valeur digest par 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 par os et arch.