Suivi — état d’avancement & tâches à venir
Document unique de suivi vivant. C’est le point d’entrée « où en est-on / quelle est la prochaine tâche / par où on est passé / qu’a-t-on fait ». Il est tenu à jour au fil des merges (chaque jalon met à jour l’état + coche/ajoute une tâche). Référencé par
CLAUDE.mdetAGENTS.md.Rôle des autres documents (pour ne pas les confondre) :
INDEX.md— carte structurelle : ce qui existe, où le trouver.JOURNAL.md— archive chronologique append-only (le détail session par session, PR par PR). On y écrit l’historique ; on ne le « maintient » pas comme tableau de bord.roadmap-couche1-libc-pal-fr.md— plan détaillé §-numéroté de la clôture couche 1 (les specs/audits y renvoient via « §5.3 P0.x »). Le statut vivant de ce plan est tenu ici.
🗺️ ROADMAP ORDONNÉE — la carte, au 2026-08-15
Lire ceci en premier. Cette section dit où en est chaque front et dans quel ordre reprendre. Elle a été établie en vérifiant le code, pas en relisant des notes : chaque affirmation ci-dessous a été mesurée le 2026-08-15. Les chiffres sont datés parce qu’ils vieillissent — un chiffre sans date est une affirmation qui se périme en silence.
⚠️ Un chantier est INTERROMPU, pas abandonné :
air-launchdtemps 3b, et le passage d’air-sshdsous sa supervision. Il a été mis de côté le 2026-08-14 pour traiter la règle NO GO et l’outillage manquant. Il reprend en tête de cette roadmap.Mise à jour du 2026-08-30. 3b a repris : son incrément d’observation est fusionné (#873), et le gel d’
air-launchdposé le 2026-08-24 est levé. La sectionair-launchdci-dessous est à jour à cette date ; le reste de cette roadmap date toujours du 2026-08-15 et vieillit — un chiffre sans date est une affirmation qui se périme en silence, et celle-ci a quinze jours.
État par front
air-launchd — supervision
| Temps | État | Ce que c’est |
|---|---|---|
| 1 — canal de lancement | fait | six enregistrements, écoutes remises et non décrites (spec v1.0) |
2 — mise en cage à l’exec | fait | cage dérivée de l’octroi, appliquée avant execve |
| 3a — multi-service | fait (#723) | balayage de /var/lib/air/services/, bloc service au manifeste, un seul ppoll sur N services |
| 2b-ii — registre AirCom hébergé | fait (#723) | le lanceur héberge le registre de noms ; dernier mur du plafond d’octroi tombé |
| bornage cgroup (spec §4.5.1) | fait | --max-processes / --memory-high, défauts posés sans être demandés, unlimited explicite, cgroup.kill ; crates/air-cgroup + supervise.rs. ⚠️ Cette ligne disait « RESTE » le 2026-08-15 — c’était faux, corrigé le jour même après vérification du code. |
| 3b — dépendances et ordre | FAIT — les quatre tâches sont closes (2026-09-07) | Voir le détail juste sous ce tableau. Cette ligne disait « EN COURS, au tiers (au 2026-08-30) » jusqu’au 2026-09-07 : cinq PR l’avaient dépassée sans que le suivi bouge. |
jalon B (ADR-168) — air-sshd sous air-launchd | ✅ FRANCHI le 2026-09-07 | Sur carbon, le critère d’ADR-168 D2 est littéralement vrai : air-sshd.service désactivée et supprimée du disque, air-launchd.service active et enabled, et le démon sert une session SSH complète sur le port 22 sous le lanceur. Voir le détail sous ce tableau. ⚠️ Le jalon avait été annoncé franchi deux fois à tort — le 2026-09-04 (le harnais rendait ok par un return, sans que le démon ait jamais démarré) et le 2026-09-05 (la preuve en test confondue avec le critère, qui est un état de MACHINE). |
Jalon B — comment il a été franchi (2026-09-07)
L’état de la machine, relevé après la bascule :
air-sshd.service ABSENTE (désactivée, supprimée du disque)
air-launchd.service active, enabled → survit au redémarrage
0.0.0.0:22 air-sshd, pid 3047388 → SOUS le lanceur, cagé
0.0.0.0:52222 OpenSSH → accès d'administration, intact
Une session s’ouvre, le shell rend ses commandes, et le code de sortie est 0.
Trois refus dans cette session ne sont pas des défauts — ce sont des preuves que la cage
tient : /etc/bash.bashrc et ~/.bashrc sont refusés par Landlock (le manifeste ne les
demande pas), et ps meurt d’un Bad system call — un SIGSYS, donc un appel hors du
profil seccomp. Ce qui n’est pas déclaré n’est pas accordé (ADR-181).
Ce que le premier lancement réel a coûté, et rapporté. Onze défauts trouvés en une
journée, chacun par un refus du noyau, aucun par un test existant — parce que le harnais du
jalon B construit sa ServiceSpec en mémoire et ne lit ni le manifeste, ni l’octroi, ni
le plafond. Il prouve le chemin d’exécution ; il ne disait rien du chemin d’installation.
Les corrections ont produit : la catégorie seccomp Privsep (17 appels entrés au
vocabulaire), le gabarit {home} (ADR-180), le champ transitionalGlibc au manifeste,
et la doctrine du bon citoyen (ADR-181). Détail dans #958 et au journal.
Ce qui restait à faire, et qui a été fait
La preuve en test était acquise depuis le 2026-09-05, avec ses deux conditions — ADR-172 D4 (le démon publie son nom) et ADR-174 D4 (les bornes mesurées). Restaient trois points, dont un seul demandait un arbitrage.
| # | Ce qui manque | Nature |
|---|---|---|
| 1 | air-launchd.service n’est spécifiée nulle part | fait — ADR-168 D2.1 (arbitrage BDFL) : le lanceur se place dans une feuille de sa propre hiérarchie cgroup, et son unité porte Delegate=yes. Sans cette ligne, la règle no internal processes lui interdit de distribuer pids/memory, et toutes les bornes tombent en silence |
| 2 | io_uring, ni le port, ni sa configuration | fait le 2026-09-07 (#953) — un manifeste incomplet est un service qui ne peut jamais démarrer, l’octroi ne pouvant excéder la demande |
| 3 | fait — ADR-168 D2.2 (cohabitation : le port distinct d’abord, la vérification, PUIS le retrait) et docs/guides/bascule-jalon-b-fr.md. Sauvegarde complète conservée sous /root/air-jalon-b-sauvegarde-2026-09-07 sur carbon |
Le bundle est installé sur carbon (/var/lib/air/services/org.air-desktop.sshd/), son octroi
signé sous /etc/air/grants/, et les deux ont été refaits le 2026-09-07 avec le manifeste
corrigé — celui d’avant datait du 2026-07-24.
3b — le détail, remesuré contre le code le 2026-09-07
Fait et fusionné :
- Incrément 1 (#793) — schéma
requires/Readiness, refus sur le seul manifeste. - Incrément 1b — le graphe : tri topologique déterministe, cycle nommé, fermeture
transitive.
discovery.rsporteordre_topologique()ettri_topologique(). - Observation de
published(#873) —AirRegistry::names_published_by(pid)en couche 1,RegistryHost::has_published(pid)en couche 2, etServiceSpec::readinessporté depuis le manifeste commerequiresl’est déjà. Le lanceur constate la publication sur la table ; le service ne la lui annonce pas.
⚠️ Ce paragraphe disait, jusqu’au 2026-09-07 : « supervise_many ignore l’ordre
topologique que discovery calcule […] le readiness livré par #873 ne conditionne rien. »
C’était vrai le 2026-08-30, et faux depuis le 2026-09-01. #889 a rendu le démarrage échelonné le jour même, et quatre PR ont suivi. Le laisser sept jours de plus aurait fait recommencer un travail déjà fait — c’est exactement le coût que ce document existe pour éviter, et il l’a payé cette fois-ci.
Ce que le code fait aujourd’hui, vérifié ligne à ligne :
supervise_many.rsne lance un service qu’après avoir constaté que chacun de sesrequiresest présent, non échoué et prêt (bouclefor besoin in &requires) ;rafraichir_pretinterrogeRegistryHost::has_published(pid)pour un servicereadiness = Published, et dit le franchissement (Report::DevenuPret) ;EXPECT_READYne subsiste que dans quatre commentaires qui racontent son retrait — aucun code ne le pose ni ne le lit.
Reste, dans l’ordre imposé par l’ADR :
| # | Tâche | ADR |
|---|---|---|
supervise_many suit l’ordre | D3 | |
has_published | D2 | |
READY 16 ms au pire, STOP 2 ms, 20 tours, 0 écarté. Relevé reproduit à l’identique le 2026-09-07. Les bornes restent à 30 000 / 10 000 ms — arbitrage BDFL du 2026-09-03, et parce que la mesure porte sur un démarrage tiède (cf. ADR-174 D4) | D6 | |
EXPECT_READY | D2 | |
stop_everyone procède par passes, et la décision vit dans la fonction pure arretables | D5 | |
rendre_a_l_attente, décision dans la fonction pure dependances_perdues ; gouverné par ADR-171 (remise en attente, crédit à la faute, transitivité par les passes) | D4 |
(La contrainte « 2.4 ne peut pas venir avant 2.1+2.2 », qu’ADR-161 D2 posait — « le retrait
n’intervient qu’avec la mise en œuvre de published, jamais avant » —, a été respectée : les
trois sont tombées dans cet ordre.)
3b est donc close. La dernière case, 2.3, l’était depuis le 2026-09-05 sans que ce document le sache — ADR-174 D4 porte le relevé et l’arbitrage. Un second relevé, lancé le 2026-09-07 pour vérifier que la première mesure n’était pas un coup de chance, rend les mêmes chiffres au millième près (16 ms / 2 ms au pire, 20 tours, 0 écarté, même Pi, même noyau). Une mesure reproduite vaut mieux qu’une mesure citée.
Décisions prises et à ne pas rouvrir :
- Le gel d’
air-launchd(décision BDFL du 2026-08-24) est LEVÉ le 2026-08-30. Les deux dérogationsSECOND-PATHqu’il retenait — appel direct àair_poll::pollau lieu de la porte — tombent avec lui. Le domainepollest alors entièrement médié. - La marge du délai de D6 : un ordre de grandeur au-dessus du pire observé, arrondi à une valeur lisible. À appliquer une fois le chiffre mesuré, jamais avant.
- La mesure sur le Pi passe par un job CI
workflow_dispatchsur le labelair-aarch64, jamais par SSH : un balayage lancé à la main a tué un job de CI le 2026-08-17.
air-sshd / air-ssh — le passage sous air-launchd
Le code est déjà compatible, et c’est le fait le plus important de ce front.
air-service est par conception la couture v1(systemd) → v2(air-launchd) ; AirLaunchdHost
existe (crates/air-service/src/launchd.rs) ; et air-sshd appelle déjà
air_service::detect(), qui aiguille statiquement entre les deux superviseurs. Il n’y a
donc rien à réécrire dans le démon.
Ce qui manque est ailleurs, et c’est du déploiement :
- le bundle
org.air-desktop.sshd— l’identifiant et un manifeste existent (crates/air-sshd/src/bundle_provision.rs), reste à le produire et l’installer ; - l’octroi sur chaque machine (
air-bundle grant, geste d’administration humain) ; - l’installation sous
/var/lib/air/services/<bundleId>/pour que le lanceur le trouve ; - la retraite de l’unité systemd
crates/air-service/units/air-sshd.service, qui reste aujourd’hui le superviseur réel sur les trois machines.
air-ssh (client) et air-sshd (démon) sont tous deux livrés par le paquet air-sshd.
Lignes de commande
Six paquets livrés : air-account, air-keystore, air-bundle, air-launchd,
air-sshd (deux binaires : air-ssh + air-sshd), air-agent.
air-bundle porte cinq verbes : developer-key, grant, install, revoke, show.
La politique de saisie du mot de passe administrateur est alignée sur les trois outils
d’administration (invite quand l’entrée est un terminal, lecture brute sinon), depuis une
implémentation unique dans air-admin-gate.
Il n’existe pas de commande air unifiée. Ce n’est pas un manque constaté mais un choix
non tranché : à décider si les verbes doivent un jour se ranger sous un point d’entrée unique.
Couches, contrôleurs et managers (ADR-149 / ADR-152)
cargo xtask check-controleurs : PASS, 91 crates scannées, 19 managers déclarés.
Le cliquet xtask/controleurs-reference.toml inventorie 23 entrées d’écart sur 21 crates
(air-libc-capi et air-libc-spawn en cumulent deux), dont 5 managers absents — et
19 crates déclarent role = "manager" quand 14 en portent un :
Baisse du 2026-08-21 :
air-terminal(AirTerminalManager, 21 opérations coordonnées) etair-account(AirAccountManager, 38 opérations — la plus grosse dette du registre) sont soldés, et tous deux raccordés àair-security. 26 → 24 entrées, 24 → 22 crates, 11 → 13 managers portés.Baisse du 2026-08-22 :
air-device(AirDeviceManager) — le moins cher des huit, sa surface objet était complète et seul le point d’entrée manquait. 24 → 23 entrées, 22 → 21 crates, 13 → 14 managers portés. Le registre ne peut que rétrécir.
| Crate | Note |
|---|---|
air-thread | 14 opérations libres |
air-config-compile | 10 opérations libres |
air-crypto | 7 opérations libres |
air-config | 4 opérations libres |
air-sandbox | mécanisme livré, médiateur absent — débloqué le 2026-08-22 (ADR-150 D12/D13) : plus de dépendance de décision |
air-security — le vocabulaire commun de la sécurité active — est né de ces deux
fermetures : écrits en parallèle, les deux managers avaient produit deux vocabulaires ET
deux formes pour dire la même chose. Chaque domaine ne garde désormais que la liste de ses
opérations sensibles.
Les deux sont des managers additifs : les fonctions libres restent
en route parallèle jusqu’au descellement unique de fin de campagne, comme air-uring et
air-keystore. Détail dans JOURNAL.md, y compris les trois défauts
latents que le manager ferme (injection de ligne dans la projection /etc/passwd, mode
0600 sur une base qui doit être monde-lisible, garantie de mode que l’appelant ne peut pas
donner) et l’écart d’ADR-067 constaté au passage.
Les autres écarts se répartissent en bridge-vers-controleur (7), controleur-fuite (4),
vocabulaire-impur (3), syscall-hors-couche-1 (2) et service-consomme (2) — relevé
contre le registre le 2026-08-22, l’ancien compte annonçant 4 bridges au lieu de 7. Le registre rétrécit, il ne se remplit pas :
y ajouter une ligne pour faire passer une PR est une régression.
La couture SecurityManager (ADR-089)
entre désormais dans chaque manager du rang 1 au moment où il est écrit — décision BDFL du
2026-08-21, première pose dans AirAccountManager. L’ADR passe a-faire → en-cours : le
seam existe, le moteur reste hors sujet (ADR-089 D5 le diffère explicitement).
Question ouverte consignée dans statuts.toml : le trait vit aujourd’hui dans air-account
— le hisser dans une crate partagée est une décision transverse, à trancher avant le
troisième manager.
L’ordre, et pourquoi celui-là
Monter le nightly épinglé.✅ FAIT le 2026-08-15 — pin ànightly-2026-07-11, le plus ancien corrigé (dichotomie faite). Le patch PAL a été porté : deux morceaux de contexte dansos/fd/owned.rs, etsys/sync/once/mod.rsoù l’amont a introduit une sélectioncfg_select!dontairétait absent — d’où unno_threadsrefusant de compiler sur une cible AVEC fils. Rust 1.99 exige en outre-Z json-target-spec. Prouvé : le reproducteur rend0sur le nouveau pin et139sur l’ancien, et les six paquets Debian se produisent en 4 min.air-launchdtemps 3b — EN COURS depuis le 2026-08-20 (PR #793, ouverte et bloquée). ADR-161 a été rectifié sur trois points le 2026-08-20 après vérification contre le code : l’arbitrage derequirespasse par l’empreinte du manifeste et non par une famille d’Entitlements(D1) ; le canal de readiness que l’ADR croyait écarter existait déjà, inerte et inatteignable, et sera retiré avec la mise en œuvre depublished(D2) ; le refus au chargement est par service et transitif, jamais global (D7). Détail dansJOURNAL.md. Rédaction d’origine ci-dessous. ADR-161 écrit le 2026-08-15 (dépendances au manifeste arbitrées par l’octroi ; « prêt » = publication du nom dans le registre AirCom que le lanceur héberge déjà ; une seule relationrequires; une dépendance morte arrête ses dépendants ; arrêt en ordre inverse ; délai de « prêt » borné, valeur provisoire non mesurée). Reste la mise en œuvre : deux champs de schéma, un séquenceur sur leppollunique, et les refus au chargement. Le bornage cgroup n’en fait pas partie : il est déjà fait.- Passer
air-sshdsousair-launchd— bundle, octroi, installation, retraite de l’unité systemd. Ne dépend pas de 3b pour un service unique. - Trancher les 27 symboles au cliquet ABI : 20 ré-exports
air_value_*(décision prise : cesser de les exporter, suppose quelibair_object_c.sose lie àlibair_value_c.so) et 7 helpers découverts le 2026-08-14 (air_fmt_*,air_libc_*), non tranchés. - Les managers absents — il en reste 7 :
air-accounta été soldé le 2026-08-21. Le suivant par charge estair-terminal(21 opérations libres), qui est aussi la clé de tout l’applicatif texte (ADR-008/ADR-026).air-sandboxreste bloqué tant que sa surface dépend de l’octroi. - Les 40
DEPENDENCIES.mdmanquants, puischeck-dependencies --bloquant. - Les 46 blocs
unsafede production sans justification, puis viserclippy::undocumented_unsafe_blocks(le régime dur, qui voit l’arbre syntaxique). air-elf(ADR-159) — le codec de couche 1, puis la vérification du seuil d’exécution dansair-bundle: aujourd’hui rien ne distingue un static-PIElinux-aird’un binaire glibc étranger.- Variadiques en Rust (ADR-160 D1/D2) + gate
check-toolchain(D3).c_variadicest stabilisé en Rust 1.99 (1ᵉʳ octobre 2026) : le coût « premier#![feature]du dépôt » disparaît au passage à cette version. cargo xtask deben CI — l’empaquetage n’est éprouvé que par des lancements manuels.
Décisions qui appartiennent au BDFL
- Monter le nightly malgré le portage du patch PAL (point 1).
required_signaturessurmain: toujours désactivée. Un gate ne peut pas y suppléer — un runner n’a pas les clés publiques des contributeurs, il constate qu’une signature est apposée, jamais qu’elle est valide.docs/principes-ingenierie/est IMMUABLE et affirme queair-abi-check,air-symveretair-deprecation-trackersont « introduits dès la phase 0 ». Aucun n’a jamais existé. À réconcilier par RFC (ADR-015). Le même document décrit un test qui « charge un binaire de référence compilé contre une version antérieure » — ce que les testsabi_conformancene font pas : ils compilent contre le header courant.- Une commande
airunifiée : à trancher, ou à écarter explicitement.
🛡️ RÈGLE NO GO + quatre gates qui manquaient (2026-08-14)
En tête de
CLAUDE.mdet d’AGENTS.md: un outil ou un référentiel affirmé existant et qui ne l’est pas arrête tout ; un agent qui écrit qu’un sujet est traité doit avoir tenu cette promesse en l’écrivant. Onze occurrences en trois jours l’ont payée.Ce que l’inventaire a rendu — quatre gates écrits (#733 et suivant) :
Gate Régime Premier passage check-dcobloquant historique conforme — la règle tenait, sans filet check-abibloquant 392 déclarations figées ; rupture d’ABI désormais refusée check-dependenciesconsultatif 40 crates en écart sur 91, 23 sans DEPENDENCIES.mdcheck-safety-commentsconsultatif 46 blocs unsafede production sans justification sur 1909Prochaines tâches issues de ce chantier — dette nommée, pas refoulée :
- écrire les 40
DEPENDENCIES.mdmanquants, puis passercheck-dependencies --bloquant;- justifier les 46 blocs
unsafede production, puis viserclippy::undocumented_unsafe_blocks;- décision BDFL : activer
required_signaturessurmain— un gate ne peut pas y suppléer, un runner n’a pas les clés publiques des contributeurs ;- décision BDFL :
docs/principes-ingenierie/(IMMUABLE) affirme queair-abi-check,air-symveretair-deprecation-trackersont « introduits dès la phase 0 ». Aucun n’a jamais existé. À réconcilier par RFC (ADR-015).
✅ DÉPLOYÉ — les trois machines portent la chaîne complète (2026-08-14)
speedy, carbon et raspi-srv-2 portent les six paquets, dont
air-bundleetair-launchdpour la première fois. Trois clés de signature distinctes —c6a36f1b…,cb11040e…,a1aabea4…: chaque machine signe ses propres octrois, aucune ne valide ceux d’une autre.air-launchdtourne sur aarch64, ce qui n’avait jamais été éprouvé.Ce que le déploiement a prouvé, et que les tests ne pouvaient pas dire : l’idempotence de la clé machine a survécu à une vraie mise à jour de paquet (et non à un second appel de test) — « déjà présente — inchangée », empreinte identique, fichier non réécrit.
Ce que le déploiement a RÉVÉLÉ — trois défauts que le vert de la CI masquait : rien ne générait la clé de signature (le
postinstd’air-sshdéchouait donc sur toute machine) ;cargo xtask debne compilait plus depuis #627 ; et rien ne pouvait installer un bundle de service. Corrigés par #727 et #729.Ce qui reste, et n’appartient pas à un agent : l’émission des octrois. Le gate d’ADR-101 est actif sur les trois machines (vérifié) et réclame le mot de passe administrateur sur la première ligne de
stdin. Un shell root ne suffit pas à accorder — c’est le but.printf '%s\n' '<mot-de-passe-admin>' | sudo air-bundle grant --bundle <id> --<droit> …Angle mort nommé : la CI des PR ne lance pas
cargo xtask deb. Elle aurait attrapé deux des trois défauts ci-dessus. C’est le meilleur rapport valeur/effort du moment — il empêche une classe entière au lieu de la découvrir après coup.
🆕 ADR-157 — le lanceur supervise ce qu’il trouve (2026-08-13)
Chantier multi-service, temps 3a. Le dernier reste nommé du temps 3 de la spec du lanceur qu’aucun ADR ne décidait — et le blocage de l’étape 2b-ii : héberger le registre AirCom dans un lanceur mono-service donnerait un registre à client unique.
Le découpage n’est pas de commodité : 2b-ii n’a pas besoin des dépendances, seulement de plusieurs services vivants dans un même lanceur. 3a livre cela ; 3b (dépendances, ordre, arrêt inverse) aura son propre ADR.
D1 le lanceur balaie
/var/lib/air/services/<bundleId>/et supervise ce qu’il trouve, pas ce qu’on lui dit — acceptable parce que l’octroi arbitre. D2 un blocserviceoptionnel dans le manifeste (point d’entrée parmi les exécutables empreintés, arguments, redémarrage) ; sans lui, pas de supervision — deviner serait la tolérance que le Principe 11 refuse. D3 démarrer n’est pas un privilège : ce bloc est une déclaration, pas une demande de droit, et l’invariant demande↔octroi ne s’y applique pas. D4 le lancement parargvdevient un second chemin marqué (ADR-149 D10) — ne pas le marquer reproduirait ce qu’ADR-153 a dû défaire pour le champ 7. D5 l’ensemble supervisé est borné, leppollvit sur la pile.
🆕 ADR-156 —
air-sandboxcible l’ABI Landlock v7 (2026-08-13)La règle d’abord : on n’expose pas ce dont on a besoin, on expose ce que le noyau offre. Un développeur qui écrit pour Air doit y trouver toute la capacité de confinement d’un noyau 6.x/7.x, pas la part qu’Air a consommée.
Le constat qui déclenche.
air-sandboxn’expose niREFER(v2), ni le scoping (v6), ni l’audit (v7) — alors que le plancher d’Air (6.12) vaut v6. Le manque n’est plus une prudence, c’est un retard.Et un écart mesuré :
deny_all()ne gère que les droits v1. Or un droit non géré est un droit non restreint — mécanisme exact de l’EXECUTEdu 2026-08-12. Une cage qui se présente comme une fermeture totale ne restreint donc aujourd’hui niTRUNCATE, niREFER, niIOCTL_DEV. Ce n’est pas une faille démontrée — seccomp borne le verbe pour les profils en service — mais c’est un écart entre ce que la cage annonce et ce qu’elle gère, soit précisément ce qu’ADR-112 existait pour supprimer.Cinq décisions : cible v7, qui se déplace avec le noyau (une v8 n’exigera pas de nouvel ADR) ; ce qui est au plancher est exigé (Air refuse, il ne dégrade pas) et ce qui est au-dessus est sondé ;
deny_all()gère tout ce que l’ABI constatée permet ; le scoping reçoit sa propre surface (ce n’est pas un droit par chemin) ; l’audit est opt-in.Évolution additive de la couche 1 — elle rejoint l’inventaire du re-sceau unique.
✅ Étape 2 (AirCom) — CLOSE : le lanceur héberge le registre, le dernier mur tombe (2026-08-13)
2b-ii est livrée.
launchcrée une paire de sockets par service et en dépose un bout avant le fork, par le sixième enregistrement du canal ; la boucle multi-service guette l’autre bout et sertRegister/Lookup;admitpose la politique au lancement,forgetla reprend à la mort constatée — jamais à l’arrêt demandé, un service qui refuse de s’arrêter gardant son pid donc ses droits. Côté service,air-com::RegistryClientpublie et joint dans le vocabulaire du manifeste (publish/connect), la traduction versregister/lookuprestant chez le lanceur.La capability est remise à TOUS les services, même sans règle
aircom: la tenir n’accorde rien, et une remise conditionnelle obligerait le service à deviner s’il doit l’attendre — donc à tolérer les deux cas, donc à pouvoir se tromper.Le mur
aircomdu plafond tombe, et c’était le dernier : plus aucune famille d’octroi n’est refusée faute d’applicateur (ADR-150, option B).Un doublon retiré en chemin : le registre répondait en
b"oui"/b"non", alors qu’air-com-protodéfinissait déjàRegistryResponse. Le type existant est aussi le meilleur — il ne confondNotFoundetDeniedque pour un demandeur non habilité, là où un refus uniformément muet aurait condamné tout client à réessayer indéfiniment un refus définitif.Le blocage qui a été levé (constat du 2026-08-13, conservé)
Les deux moitiés additives sont livrées : la politique (#714 — la famille
aircomd’un octroi devientGrantedServices,connect→allow_lookup,publish→allow_register, oubli à la mort constatée) et le vocabulaire du canal (#715 — sixième enregistrement, capability remise enSCM_RIGHTS).Reste l’hébergement, et il est bloqué.
air-launchdsupervise un seul service par invocation (parse_argumentsrend unServiceSpec). Or un registre existe pour mettre plusieurs services en relation : l’héberger aujourd’hui donnerait un registre à client unique, dont la propriété essentielle ne serait jamais exerçable en production — la « permission que rien n’applique » que l’option B d’ADR-150 refuse.Le prérequis n’appartient pas à la campagne des managers : c’est le passage à plusieurs services, que la spec du lanceur liste dans les restes de son temps 3 (« dépendances, ordre, redémarrage »). Le découvrir avant de construire vaut mieux que de livrer un hôte intestable.
L’étape 4 (
devices) n’a pas ce blocage : un octroi de périphérique se traduit en règles Landlock sur des chemins, par service, au lancement. Vérifié :landlock_policy_forne traite aujourd’hui que la famillefilesystem. Le travail est réel mais autonome — c’est la suite naturelle.
🆕 ADR-155 — la chaîne de construction du kernel est décidée (2026-08-13)
En préparation de l’image installable. Cinq décisions, prises dans cet ordre parce que chacune contraignait la suivante :
D1 le kernel se bâtit avec Clang/LLVM (
make LLVM=1), et GCC n’est pas ajouté par-dessus. D2 il y a du Rust dans le kernel — ce qui fait basculer le cadre : la question cesse d’être « quelle chaîne » pour devenir « une chaîne, ou deux », puisque LLVM est requis quoi qu’il arrive (rustcest LLVM,bindgenutiliselibclang). Choisir GCC n’aurait donc pas retiré LLVM du lot : cela y aurait ajouté GCC. D3 Air ne livre que son propre kernel. D4 une seulerustcpour le userland et le kernel. D5 parité de version LLVM entreclangetrustc— condition pour que kCFI couvre le Rust et que le LTO s’étende à tout le noyau.Ce que D3 dissipe : le plancher 6.12 (LTS) est un plancher d’exécution — ce sur quoi le userland tourne — et n’a jamais rien dit du kernel qu’Air bâtit. La précision est désormais posée dans
macro-architecture-fr.md, au point exact où le 6.12 est énoncé.Ce que GCC aurait apporté ne pèse pas :
latent_entropyetstackleakne sont actifs par défaut ni l’un ni l’autre — options opt-in,CONFIG_GCC_PLUGINS, coût en performance —, et leurs substituts existent des deux côtés (INIT_STACK_ALL_ZERO, entropie matérielle). Bâtir avec GCC ne les donne pas : cela donne le droit de les activer.Prix assumé de D4 : monter de version devient un geste couplé, userland et kernel ensemble. D3 le rend payable — sans contrat de compatibilité de modules, c’est une manœuvre coordonnée, pas une rupture.
Vigilance contre-intuitive, écrite dans l’ADR : le risque n’est pas une
rustctrop ancienne mais trop récente — le Rust du kernel s’appuie sur des fonctionnalités instables du langage. Air épingle 1.96.0 ; l’appariement naturel est un noyau contemporain.Reste à outiller : un gate vérifiant que la
rustcépinglée est celle que le noyau accepte (make LLVM=1 rustavailable). Sans lui, D4 et D5 ne vivent que dans un document.
✅ ADR-153 — CLOS (2026-08-13)
#702 → #705 mergées. Le champ 7 de
/etc/passwda cessé d’être une porte d’exécution : ce qu’une session lance vient désormais d’un artefact signé, inventoriable et révocable, arbitré par le monitor avant la commande. Le second chemin d’ADR-149 D10 a disparu au lieu d’être marqué.Prochaine tâche : l’interblocage des pompes de redirection (
forward_demux_blocking::a_forward_joins_a_session_already_under_way), reproduit le 2026-08-12 — deux fils dans unppollsans échéance, 2 h 22 de blocage. #706 le rend observable (borne + message nommant l’attente).La lecture du code (note dédiée) a éliminé trois hypothèses — demande de
SIGHUPperdue,SIG_IGNhérité à travers l’exec, monitor qui cesse de lire pendant qu’il doit une annonce — et en a établi une autre : le test attend davantage que ce que le pilote promet.session_blocking.rsénonce que si la mort du processus n’est jamais constatée, le canal reste semi-fermé et les redirections continuent d’être servies — état conforme du point de vue du démon. Maiscollect_session_endattend leCLOSEsans condition.Le rouge intermittent n’est donc peut-être pas un défaut du démon, mais un test qui attend une garantie que personne n’a donnée. Ce qui reste à trancher — shell non mort, ou mort non constatée — n’est pas décidable par lecture ; #706 le dira à la prochaine occurrence.
Question 3 tranchée le 2026-08-13 — ADR-153 n’a plus aucune question ouverte. Le
SubsystemSFTP ne rejoint pas la famille, et la raison est structurelle : il ne lance aucun programme. Il est servi en processus dans le worker, cage déjà posée, sans même une variante dans le protocole de médiation (« il ne se lance pas, il se sert »), et aucun chemin de sous-système n’est configurable. L’y faire figurer créerait une règle sans référent — ce que le plafond refuse ailleurs, et pour la même raison.Cela ne le laisse pas sans gouvernance : ce qu’il fait est borné par la cage, par
MaxSessions, et par le fait qu’il ne détient aucun descripteur. C’est la distinction que l’ADR pose lui-même — la cage borne ce qu’un programme fait, pas lequel démarre. Rouvrirait la question : unSubsystemdésignant un binaire externe.
ADR-153 — le provisionnement, et un défaut trouvé en l’écrivant (2026-08-13)
Le paquet pose le bundle et un octroi VIDE. Sans octroi du tout,
air-sshdne démarre pas : une mise à jour laisserait la machine sans démon. Avec un octroi vide, il démarre, sert ses redirections, et n’ouvre aucune session tant que l’administrateur n’a rien admis. Un octroi vide n’accorde rien — il n’usurpe donc aucune décision ; il rend l’état sûr et visible, ce qui est exactement ADR-153 D5. L’administrateur qui installe est devant la machine : sa session en cours survit, et il accorde dans la foulée.Qui signe le manifeste — décision BDFL du 2026-08-12. Une clé « manifestes de service » naît dans le keystore et signe localement ; [ADR-010] l’autorise (« développeur : self-signed possible »). Elle est distincte de la clé de machine, parce qu’ADR-150 tient séparés deux rôles : le développeur demande, la machine accorde — une seule clé ferait de la machine l’auteur de sa propre demande. Phase transitoire déclarée et datée : la cible reste une clé de release du projet, signant à la construction ; le coût assumé en attendant est que l’artefact de manifeste diffère d’une machine à l’autre, donc n’est pas reproductible au sens d’ADR-025.
bundleVersionne suit PAS la version du démon. L’octroi épingle le condensat du manifeste : s’il changeait à chaque version, chaque mise à jour invaliderait l’octroi de l’administrateur. Il porte donc la révision de la déclaration, et ne bouge que si ce qui est demandé change — auquel cas invalider est précisément ce qu’il faut faire.⚠️ Un défaut réel trouvé en écrivant ce code.
write_entitlementsn’écrivait pas la famillesession. Le lecteur savait la lire, l’intersection la calculer, l’invariant la comparer — mais tout octroi de programme de session la perdait en franchissant le disque, en silence. En production : un administrateur accorde/bin/sh, l’artefact ne le porte pas, toute session est refusée. La fonctionnalité entière d’ADR-153 n’aurait jamais marché.Aucun test unitaire ne pouvait l’attraper : ils exerçaient l’émission et la lecture séparément. C’est la traversée qui prouve, pas les deux moitiés. Le test ajouté couvre les cinq familles — ce qui est arrivé à l’une peut arriver à la suivante — et il a été vérifié mordant. Corrigé dans #704 avant merge.
ADR-153 — le champ 7 cesse d’être une porte d’exécution (2026-08-12)
Incrément 2, en deux temps.
2a — le vocabulaire quitte le lanceur. Le module
bundlereprésentait 8 049 des 8 310 lignes d’air-launchd(97 %), dans une crate qui déclare « n’expose rien de réutilisable ». Faire lire son octroi àair-sshd— un démon root — depuis le superviseur aurait rendu cette contradiction vraie une troisième fois. Extrait enair-bundle: un déplacement, aucun changement de comportement, 236 tests répartis sans perte. La séparation a rendu mesurable ce qui n’était que déclaré —air-confign’est qu’une dépendance de test du cœur pur, qui reçoit des readers déjà validés.2b —
air-sshddevient consommateur d’octroi. Il lit le sien au démarrage sous l’identifiantorg.air-desktop.sshd— le premier identifiant de bundle réel d’Air, qui fixe donc la conventionorg.air-desktop.<service>. Le monitor oppose la liste admise au champ 7 avant la commande (D4) :ForceCommandcontraint désormais ce qu’on exécute à l’intérieur d’un programme admis, au lieu d’être la seule borne. Le refus est muet vers le client et tracé au journal avec les trois éléments qu’ADR-153 exige — le compte, le programme refusé, et la liste admise, sans laquelle un octroi mal orthographié se diagnostiquerait par devinette. Le mur du plafond posé à l’incrément 1 tombe, etair-bundle grant --session-programaccorde enfin.Les tests d’arbitrage ont été vérifiés mordants : la garde neutralisée, les trois tests de refus échouent et celui d’acceptation passe toujours — ce qui distingue un arbitrage d’une panne.
⚠️ Conséquence d’exploitation, à dire avant de déployer. Pas d’octroi, pas de service (tranché par le BDFL le 2026-08-11). Une machine mise à jour sans avoir reçu son octroi ne démarrera plus
air-sshd. Le message de refus porte le geste qui répare, parce que celui qui le découvre n’a, par construction, plus de session pour aller lire la documentation — mais il faut provisionner avant, pas après. Les trois exécuteurs (speedy, carbon, raspi) sont concernés.Questions 1 et 2 tranchées : famille vide par défaut, pas absente (option B d’ADR-150) ; aucun cas particulier pour
nologin/false— non admis, ils font refuser la session, ce qui est exactement le résultat observable de leur exécution, obtenu un cran plus tôt. Reste la question 3 (SubsystemSFTP), à trancher avec ADR-119.
ADR-153 — la cinquième famille existe, et le plafond la retient (2026-08-12)
Incrément 1 : le vocabulaire et l’invariant. La famille
sessionentre dans le schéma (SessionRule { program }), dans le lecteur (chemin absolu, sans.., doublons refusés, borne par famille), dans l’intersection d’issue— par présence, pas par verbes : une règle de session n’a rien à cocher, elle est là ou elle n’y est pas —, dans le refus anti-méprise d’install, et dans l’affichage d’air-bundle. Le rapprochement est l’égalité stricte du chemin :/usr/binne couvre pas/usr/bin/alize, sinon tout ce qu’un paquet déposera demain dans ce répertoire serait admis d’avance.Le plafond REFUSE tout octroi non vide de cette famille, et c’est délibéré. Le vocabulaire existe, mais personne ne l’applique encore :
air-sshdlance toujours le shell du champ 7 sans consulter d’octroi. Laisser passer ce que rien n’applique donnerait un octroi qui a l’air d’accorder et n’accorde rien — une promesse écrite dans un fichier. Le mur est visible, daté, et tombera avec l’applicateur, comme celui du réseau est tombé le 2026-08-10. Même raison pour l’outil d’administration : il montre la famille (un manifeste peut déjà la demander, et un affichage qui l’omettrait ferait décider sur une vue incomplète) mais n’offre aucun drapeau pour l’accorder.Question ouverte n° 1 tranchée : la famille est vide par défaut, pas absente (option B d’ADR-150). Absente, chaque lecteur devrait décider ce que l’absence veut dire — « rien n’est admis » ou « la question ne se pose pas » — et deux lecteurs finiraient par choisir différemment.
Prochaine tâche — incrément 2 :
air-sshddevient consommateur de son propre octroi, le monitor résout le programme avant la commande, et le mur du plafond tombe. Restent à trancher les questions 2 (nologin/false) et 3 (SubsystemSFTP, avec ADR-119).
✅ ADR-010 — le chantier de la disposition est clos (2026-08-12)
Les huit tâches de l’instruction sont mergées (#684 → #701) : lecteur gardé, activation par
--bundle, émission d’octroi avec refus anti-méprise, clé de machine dansair-keystore, outil d’administrationair-bundle, ADR amendé, et convergence de la garde de confiance de fichier en couche 1. L’entrée ci-dessous, qui annonce « rien n’est encore implémenté », décrivait l’état du 2026-08-12 au matin — elle est conservée telle quelle pour ce qu’elle documente (la décision de disposition et l’écart trouvé en la concevant), pas comme état courant.L’écart
EXECUTEy est corrigé : la politique Landlock ajoute désormais d’office le droit d’exécution sur le programme du service, ce qui fait entrerEXECUTEdanshandled_access_fs— et rend donc non exécutable tout le reste. Mesuré au passage : un binaire dynamique exige aussi le droit sur son interpréteur ELF, ce qu’aucune lecture du code n’annonçait.
🧭 ADR-010 — la disposition du bundle est tranchée, et elle referme un écart (2026-08-12)
Décision BDFL. Instruction complète :
instruction-disposition-bundle-fr.md. Rien n’est encore implémenté — c’est l’incrément 6 de la spec couche 2.La disposition :
<racine>/<bundleId>/contenantmanifest.aircfg,manifest.signature.aircfg,executables/,libraries/,resources/. Trois racines —/usr/lib/air/bundles/(image, verity),/var/lib/air/applications/,/var/lib/air/services/. Le répertoire ne porte pas le suffixe.airapp: le schéma dit explicitement pourquoikindest dans l’artefact — « le distinguer dans l’artefact évite qu’il se déduise du chemin d’installation, qui est plus facile à influencer ». L’octroi :/etc/air/grants/<bundleId>.aircfg,0600 root:root— parce que l’enveloppe accorde la lecture de tout/etc/airà toute application signée, et qu’un répertoire d’octrois énumérable annulerait le refus muet d’ADR-150 D4..airservicepartage la disposition entièrement : deux dispositions donneraient deux lecteurs pour un seul schéma.⚠️ Écart de sécurité trouvé en concevant, et non encore corrigé.
ceiling.rsannonce « aucune exécution nulle part … c’est ce qui casse la chaîne classique télécharger puis exécuter ». La propriété n’est pas appliquée :AirLandlockPolicy::handled_access()(air-sandbox/src/landlock.rs:325) construithandled_access_fspar union des droits des règles présentes ; aucune enveloppe n’accordantexecute,EXECUTEn’est pas géré, et Landlock ne borne que ce qu’on lui déclare géré. La catégorie seccompExecétant accordée d’office, une application confinée peut écrire un binaire dans/var/lib/<bundleId>et l’exécuter. Ce n’est pas une élévation de privilèges — la cage survit à l’execveet le binaire y tombe — mais c’est la négation du seuil d’exécution d’ADR-046 D6. Constat lu dans le code, pas encore démontré par un test ; l’écrire est la tâche n° 1 de l’incrément.La disposition est ce qui le referme : le lanceur posera d’office une règle Landlock
EXECUTEpar exécutable déclaré au manifeste (pas par répertoire — le manifeste lie chacun à son SHA-256, donc seul ce qui est signé s’exécute). Même justification structurelle queSyscallCategory::Exec, déjà d’office : un bundle qui ne peut pas exécuter son propre binaire n’est pas confiné, il est mort. Effet de bord recherché : dès qu’une règle porteEXECUTE, tout le reste du système de fichiers cesse d’être exécutable.Ce que la décision retire à ADR-010 : le bundle y était « autonome et déplaçable ». Il reste déplaçable comme forme de distribution, il n’est plus exécutable en place — un répertoire à la fois inscriptible par l’utilisateur et exécutable annulerait tout le reste, et
/mediaest de toute façon monténoexec. Rétrécissement acquis ; ADR-010 étant pré-ouverture publique, l’édition directe est autorisée et la phrase sera amendée.Règle de nommage rendue explicite : la documentation est en français, les noms sur le disque sont en anglais (
grants/,executables/,manifest.aircfg) — l’usage l’était déjà (hostkeys.aircfg,passwd.aircfg,air.grant), il est désormais dit.
🧭 ADR-010 — deux traces corrigées avant d’ouvrir le chantier (2026-08-12)
En cherchant par où reprendre, une vérification a démenti le cadrage retenu la veille (« ADR-010 est le préalable, rien n’est commencé »). Une bonne moitié d’ADR-010 est implémentée depuis le 10 août, sous le drapeau ADR-150 : le vocabulaire des quatre familles d’entitlements, leur lecture validée et bornée, la signature développeur, l’applicateur
air-launchd, et la pose effective de la cage (#655, #656, #657, #659, #663). Deux traces disaient le contraire et ont été corrigées :
- la table §6 de
air-manifeste-octroi.mdannonçait l’incrément 4 « à faire » et le 5 « bloqué » — mergés depuis deux jours. L’en-tête et le §1 du même document affirmaient aussi que le lanceur « ne vérifie encore aucune signature et ne cherche encore aucun octroi ». Document réconcilié en entier, et la question ouverte n° 1 (largeur des enveloppes) n’y est plus présentée comme ouverte : elle est tranchée (#657, option B) ;statuts.tomldonnait ADR-010 poura-faire— « couche ou sous-système non commencé ». Corrigé enen-cours, avec l’inventaire de ce qui est fait et de ce qui reste.Ce qui reste est le périmètre propre d’ADR-010, et il est intact : la disposition sur disque d’un
.airapp, le chemin de l’octroi (laissé ouvert par la spec §5ter.6), l’installation qui l’émet,.airservice, et les deux niveaux de signature au-dessus du développeur (notarisation, politique locale). Le manque se mesure :spec.grantedn’est renseigné que depuis les tests — aucun chemin de production ne mène du disque à un octroi arbitré. C’est l’incrément 6 de la spec couche 2.Prochaine tâche : la disposition du bundle. Trois décisions à prendre avant tout code — le nom et l’emplacement du manifeste dans le bundle, le chemin de l’octroi hors du bundle, et si
.airservicepartage la disposition ou en a une. En réflexion chez le BDFL au 2026-08-12.La leçon de méthode est la même qu’en #682 : un état qui n’est pas relu ment sans que personne mente. Le code avait avancé de trois PR au-dessus de sa propre spec.
✅ Le shell d’Air décidé, la quiescence corrigée, le gate eBPF rendu honnête (2026-08-12)
Trois chantiers nés d’une question du BDFL : « lors d’une connexion SSH via
air-sshd, quel shell est exécuté sur l’hôte ? »ADR-153 — le programme de session est OCTROYÉ (#680). La réponse à la question était : celui du champ 7 de
/etc/passwd. Donc une décision d’exécution que l’arbitrage d’ADR-150 ne regarde pas — un second chemin ADR-149 D10 jamais reconnu — et un binaire glibc que seulTransitionalGlibcfait vivre : le seuil d’exécution tenait partout sauf sur le chemin le plus emprunté. Décidé : cinquième famille d’entitlementssession, chemins absolus et exacts (pas de glob), décision dans le monitor, programme résolu avant la commande. Le champ 7 devient une préférence, l’octroi la permission. Tranché par le BDFL : pas d’octroi, pas de service — le besoin de l’administrateur distant se traitera hors bande, jamais en abaissant le niveau de sécurité de la machine.ADR-154 — le shell d’Air s’appelle
alize(#680). Pas POSIX : l’insécurité est dans la spécification, et unbashréécrit en Rust porterait les mêmes injections. La règle structurante est D2 — une expansion ne produit jamais de nouveaux mots, qui supprime moins une classe d’injection que l’obligation de vigilance parfaite. D7 répond à l’objection du BDFL (« ne serions-nous pas une surface d’attaque pour notre propre système ? ») : sur Air un shell n’est pas privilégié, le confinement vient de la capability, pas de la pauvreté du langage. Nom propre choisi contre un nom ensh, qui promettrait unsh;-creste une ligne interprétée, parce quegit,rsyncetscpen dépendent.La quiescence io_uring (#681) — et le défaut annoncé à l’étape 0 n’était pas le bon. Le drop d’un
Reactorcoûtait 6,4 s et fuyait le fd d’anneau. Le diagnostic d’abord publié (« l’eventfd n’est jamais annulé ») a été démenti par la mesure : un réacteur ayant tourné se détruisait déjà proprement, en 137 µs, sans rien fuir. La vraie cause est plus générale — le slab compte une opération dès sa mise en file, le noyau ne la connaît qu’après l’enter; une opération jamais soumise était invisible àsync_cancelet au drainage.quiesce()soumet désormais d’abord. 6,4 s → 40 µs, zéro fuite, suiteair-uring12,9 s → 0,11 s. Correctif en couche 0, donc valable pour tout appelant d’IoUring— pas seulement le réacteur, contrairement à ce que le correctif prévu en couche 1 aurait fait.Le gate eBPF rendu honnête (#682). #681 a été refusée par un gate qui ne la concernait pas : trois lignes d’
ebpf.rscouvertes ou non d’un run à l’autre, sur la même machine et en root dans les deux cas. La cause n’était pas eBPF mais le silence — les trois sites annonçaient leur saut par uneprintln!quelibtestavale pour un test qui passe ; l’un ne disait rien du tout. Trois temps : la branche du curseur de continuation ne dépend plus d’aucune capacité (un fd invalide suffit — le curseur est écrit avant l’entrée noyau) ; le saut devient exigible là où on le déclare (AIR_EBPF_BATCH_REQUIRED=1⇒ échec portant l’errno, scopé x86_64 car un noyau ARM peut légitimement ignorer le lot) ; et la variable traversesudoexplicitement, car sur l’exécuteur primairesudo -Erépond « preserving the entire environment is not supported » et laisse tout tomber. Sans ce troisième temps le gate ne se serait jamais armé sur la machine qu’il vise : le défaut aurait été reproduit dans son propre correctif.Ce que la nuit a appris sur la méthode. Deux diagnostics faux en deux jours, tous deux plausibles et non mesurés. Un troisième constat — le segfault d’
air-libc-c— était une fausse alerte : déclaré et expliqué de longue date dansxtask/src/barrier.rs(cdylib pur dont la cible--lib, forcée parllvm-cov --branch, force-lie des#[no_mangle]pthread qui surchargent les verrous de la glibc du processus de test) ; 10 tests d’intégration verts, conformité ABI exigée en CI. D’où deux règles : un diagnostic non mesuré est une hypothèse déguisée, et avant de crier au trou de gate, chercher si l’exclusion est déclarée.Dettes ouvertes : la cause du refus de lot eBPF par
carbonreste inconnue (le prochain refus la nommera, avec son errno) ; etci.ymljustifie la restauration de propriété deCARGO_HOMEpar une préservation d’environnement qui, surcarbon, n’a pas lieu — prémisse fausse, conclusion à réexaminer.
✅ Managers dus — étape 1 :
AirKeystoreManager— FAIT (2026-08-11)Deuxième des dix. Contrairement au pilote, celui-ci a trouvé deux choses en cours de route, et la seconde est un défaut de sécurité.
Le keystore ne savait pas où vivent ses propres magasins. Les chemins canoniques (
hostkeys.aircfg,known_hosts.aircfg,trusted_ca.aircfg,revoked.aircfg) étaient déclarés dansair-sshd, couche 2 ; les cinq familles d’artefacts exposaient les mêmes quatre opérations libres prenant un chemin fourni par l’appelant, et neuf crates en dépendent. La première divergence est un trou muet : un démon qui lit ses révocations là où l’outil d’administration n’écrit pas, ce sont des révocations qui ne prennent jamais effet.La garde de lecture était posée sur le mauvais artefact.
known_hosts— matériel public, dont le pire cas est un épinglage erroné — était gardé de bout en bout (propriétaire, liens symboliques, type de fichier, droits d’écriture) par du code vivant dans le client SSH.hostkeys.aircfg, qui porte la clé privée d’hôte, etca_key.aircfg, qui forge les certificats, passaient par unread_store()de deux lignes sans aucune garde. L’asymétrie était exactement à l’envers, et sa cause est que la protection avait été écrite là où le besoin s’était fait sentir, pas là où le domaine vit — l’illustration exacte de ce que la posture d’anticipation vise à empêcher.Ce que le manager décide, et que l’appelant ne décide plus : où (l’appelant demande un artefact, pas un chemin) ; à qui il obéit (chaque lecture est gardée avant qu’un octet ne soit décodé,
NOFOLLOWà l’ouverture pour qu’aucune fenêtre ne s’ouvre entre vérifier et lire) ; et ce qu’il refuse de deviner — un magasin absent rendNone, jamais une liste vide, parce que « rien de révoqué » ne s’infère pas d’un fichier manquant.S’y ajoutent
artifact_path()etinventory(), que personne ne réclamait : dire où est un magasin et ce qu’il contient sans l’ouvrir est ce dont un outil d’administration et le provisionnement de première amorce (ADR-121) ont besoin.Question ouverte d’ADR-108 tranchée : doter la crate du médiateur, plutôt qu’amender §1. L’en-tête du crate promettait « jamais des free-functions » alors qu’il n’exposait que ça ; il dit maintenant l’état réel.
La migration est FAITE, et la route parallèle n’existe plus. Rupture de couche 1 autorisée par le BDFL le 2026-08-11 et consommée. Les 48 appels d’opérations —
air-sshd,air-keystore-cli,air-cert-issue— passent par la porte ; lesload/savepar artefact n’ont pas été rendues privées mais supprimées, le compilateur les ayant déclarées mortes une fois le dernier appelant parti.(Le chiffre de 106 annoncé d’abord était faux : il comptait les lignes contenant
air_keystore::, donc les imports de types et les références en doc. La règle de couche porte sur les opérations, pas les types — ADR-149 D1.)Trois arbitrages du BDFL en cours de route :
--storeconservé, par une dérogation nommée (at_explicit_path) qui déroge sur où, jamais sur ce qu’on vérifie ;0640retenu pour le magasin personnel, et porté en couche 1 plutôt que dans le client ;0640étendu àtrusted_caetrevoked: rien ne les lit sans privilège. Seul le magasin d’hôtes connus administré reste0644— sa diffusion est sa fonction, et le resserrer ferait échouer chaque connexion de chaque compte.Reste ouvert : le groupe dédié (
0640 root:air-users) pour atteindre « zéro other partout », qui demande le provisionnement de comptes d’ADR-121.Une revue adverse a trouvé le défaut central avant le merge, et c’était le même que celui qu’on prétendait corriger : la garde ne contrôlait que le droit d’écriture. Pour un artefact secret, ce qui compte d’abord est qui peut lire — la graine Ed25519 est stockée en clair. Un
hostkeys.aircfgretrouvé en0644(restauration d’archive sans permissions,install -m 644) était déclaré digne de confiance : clé privée d’hôte lisible par tout compte local, et l’épinglageknown_hostsn’y voit rien puisque la clé présentée est la bonne. La règle héritée d’un fichier public avait été appliquée telle quelle à un secret — la conception réactive, une couche plus bas. Corrigé :AirStoreTrust::ExposedSecret, et deux tests qui tombent quand on débranche la garde.Quatre autres constats de la même revue, corrigés : un repli fail-open dans la lecture (que la couche 2 avait déjà remplacé par du fail-closed, avec sa justification écrite) ; trois trous de zéroïsation sur le chemin des secrets, dont les copies abandonnées par les réallocations du tampon ;
inventory()qui ouvrait en lecture et échouait donc en bloc hors root, alors qu’il existe pour l’administration — il ouvre désormais enO_PATH; etrooted()qui permettait de désarmer la garde du magasin système en une ligne.Registre
xtask/controleurs-reference.toml: une entrée de moins (26).
✅ Managers dus — étape 0 :
AirUringManager— FAIT (2026-08-11)
air-uringdéclaraitrole = "manager"sans en porter aucun : premier des dix managers dus relevés par ADR-152, et le seul à n’avoir aucune opération libre — donc rien à rendre privé, donc aucune rupture. C’est pourquoi il ouvre la série : il éprouve le motif sur le cas où il ne coûte rien.Le motif, qui vaudra pour les neuf autres : le manager est additif. L’ajouter ne casse personne ; ce qui casserait, c’est de retirer les routes directes — et ce retrait est réservé au descellement unique de fin de campagne (ADR-077 : « en un seul cycle »). D’ici là,
Reactor::new/ReactorPool::new/Reactor::from_pooledrestent une route parallèle, nommée comme telle dans leur documentation.Ce que le manager apporte, et qui n’existait pas — la surface est conçue pour le développeur qui l’utilisera, pas pour l’appelant qui existe aujourd’hui (posture BDFL du 2026-08-11 : anticiper, pas réagir) :
- Demander avant de s’engager (
support()). Jusqu’ici, savoir si io_uring était utilisable exigeait d’ouvrir un réacteur et d’interpréter unErrno: poser une question demandait d’accomplir l’acte. La réponse est maintenant unAirUringSupport— opérations disponibles, propriétés négociées, buffers en anneau — sondé, jamais déduit d’une version de noyau.- Un vocabulaire qui répond à la bonne question.
AirAsyncOperationnomme ce que le réacteur sait armer, pas la table d’opcodes :IORING_OP_RECVpeut être là sans que la réception en flux le soit (elle exige en plus les buffers en anneau).- Des bornes et un défaut (
AirRingEntries). Le minimum vaut2, et ce n’est pas un goût : le réacteur réserve une entrée à son eventfd d’éveil, si bien qu’un anneau d’une entrée produit un réacteur incapable d’armer quoi que ce soit — accepté par le noyau, silencieux, indébogable. Un test l’établit des deux côtés de la frontière.- Dimensionner depuis l’affinité, pas depuis le nombre de cœurs (
open_reactor_pool_per_processor) : un service restreint à deux processeurs n’ouvre plus seize réacteurs.
air-async(couche 2) passe désormais par cette porte, ce qui a fermé le piège au passage :air_async::reactor(1)refuse au lieu de rendre un runtime muet.⚠️ Trouvé en mesurant : détruire un
Reactorcoûtait 6,4 s (créer en coûte 79 µs) et fuyait le descripteur de l’anneau. Défaut préexistant d’air-sys-syscall, qui expliquait à lui seul la durée des suites d’air-uring. ✅ CORRIGÉ (#681) — mais la cause annoncée ici (« la lecture permanente d’eventfd n’est jamais annulée ») était fausse : la mesure a montré qu’un réacteur ayant tourné se détruisait déjà proprement. Le vrai défaut était une opération mise en file et jamais soumise, invisible au noyau donc à l’annulation comme au drainage — d’où un correctif en couche 0 (et non en couche 1 comme prévu), valable pour tout appelant d’IoUring. Voir l’entrée du 2026-08-12.Registre
xtask/controleurs-reference.toml: une entrée de moins (le registre ne peut que rétrécir).
✅ ADR-150 incrément 1 — le manifeste et l’octroi existent — FAIT (2026-08-09)
Les deux artefacts d’ADR-150 sont désormais du code : schéma Cap’n Proto (
air.manifest,air.grant), lecture validée et bornée, et l’invariant qui fait tout le sens de la décision — l’octroi n’accorde jamais plus que le manifeste ne demande — écrit et testé sous ses angles d’attaque.Aucun parseur n’a été écrit. Les deux artefacts sont des domaines du registre, ce qui leur donne le compilateur texte→binaire existant (borné, fuzzé, 100 %) au lieu d’une seconde surface d’analyse syntaxique près d’une décision de privilèges. La promesse de D2 — « le développeur rédige en texte, le lanceur ne lit que le binaire » — est prouvée de bout en bout.
Statut ADR-150 :
a-faire→en-cours. Spec :specs/layer-2/air-manifeste-octroi.md.Inc. 2 fait aussi : la signature détachée (Ed25519) établit qui présente un artefact. Elle porte sur l’artefact complet, enveloppe comprise — sans quoi les mêmes octets se ré-envelopperaient sous un autre domaine avec une signature valide — et le message signé est étiqueté par domaine, parce que rien n’oblige la clé du développeur et celle de la machine à différer.
Inc. 3 fait aussi : l’émission ne prend pas « ce qu’on accorde » mais « ce qu’on propose », et rend l’intersection avec la demande — l’invariant devient inviolable par construction au lieu d’être vérifié après coup. Démontré par property-based testing, qui a au passage exhibé un vrai défaut : deux règles pour le même chemin rendaient la demande ambiguë ; elles sont désormais refusées à la lecture.
Inc. 4 fait aussi : l’arbitre enchaîne signature → octroi → enveloppe. L’enveloppe de l’image est une entrée — ce qui manque est une valeur, pas un mécanisme, et c’est ce qui a permis de faire cet incrément malgré le blocage. Le refus est muet :
Refusalne rend rien en s’affichant, le motif s’obtient parfor_journal().La largeur des enveloppes par défaut est TRANCHÉE (2026-08-10, option B) : enveloppes filesystem écrites, familles sans applicateur vides par défaut — un octroi qui les invoque est refusé. L’instruction a trouvé le vrai obstacle : le vocabulaire déclare quatre familles,
air-sandboxn’en applique qu’une. Elle a aussi révélé que l’étape 3 ne pouvait pas employer l’égalité stricte des chemins (une image est écrite une fois, pour des applications inconnues) — d’où la couverture par gabarit, sans glob.Le grain seccomp est TRANCHÉ aussi (2026-08-10, grain A) : catégories nommées (
Baseline/FdPassing/Filesystem), dérivées de l’octroi en couche 2. L’instruction a montré que la structure existait déjà, non nommée et dupliquée à la main — un test d’égalité stricte la rend désormais vérifiée, sans toucher à la surface scellée d’air-sandbox.Landlock v4 est FAIT (2026-08-10), sur les trois couches : le réseau se borne par ports (Landlock) et par verbes (catégorie seccomp
Network). Le refus sur ABI insuffisante est explicite — jamais une dégradation silencieuse. L’enveloppe Air ouvre 80 et 443, rien en écoute, et pas le 53.Au passage, un trou refermé : les 62 numéros de syscalls d’
air-sandboxn’étaient vérifiés par rien ;check-syscallsles couvre désormais (265 au lieu de 203).Reste sans applicateur : AirCom et les périphériques — un octroi qui les invoque est refusé.
✅ Temps 2 du lanceur — la cage est POSÉE (2026-08-10)
Le lanceur n’écrit plus un verdict qu’il jette : profil seccomp composé depuis l’octroi et politique Landlock (chemins + ports) sont appliqués dans l’enfant, juste avant l’
execve, et fail-closed — une cage qui ne peut pas être posée fait sortir l’enfant en125sans rien exécuter.Deux propriétés qui ne se devinent pas :
execveest toujours permis (catégorie nomméeExec) : une cage posée du dehors s’installe à l’instant qui précède l’execvedu service. Cela n’ouvre rien — la cage survit à l’execveet suit le programme.- Un binaire lié à la glibc ne démarre pas confiné, et c’est le résultat recherché (décision BDFL du 2026-08-10) : ne s’exécute sur Air que ce qui est recompilé contre la libc d’Air. Reste à faire : des profils permissifs transitoires, nommés comme tels et supprimables d’un bloc en v1.0, le temps que l’outillage en ligne de commande et les shells soient portés.
Ce qui reste explicitement à câbler : la porte par laquelle un octroi arrive dans une
ServiceSpec— chargement du bundle, puis arbitrage. Le mécanisme est complet et prouvé de bout en bout par les tests (launch()pose réellement la cage) ; ce qui manque est le chemin qui va d’un.airappsur le disque jusqu’àspec.granted. La ligne de commande ne portera jamais d’option--allow-…: une cage négociable depuis le shell n’en serait plus une.
✅ Temps 3c (i) du lanceur — le redémarrage — FAIT (2026-08-09)
Le lanceur relance : politiques
no/on-failure(défaut) /always, recul qui double (100 ms → 30 s, interruptible), crédit de 5 échecs consécutifs rendu par un démarrage qui tient 10 s. Jamais relancés : un arrêt demandé et une violation du contrat.Le lanceur garde ses écoutes — changement délibéré. Les relâcher rendait le redémarrage impossible (port libéré, pas de
SO_REUSEADDR) ; en les gardant, les connexions arrivées pendant le redémarrage attendent dans la file du noyau au lieu d’être refusées.Reste 3c-ii : plusieurs services, dépendances, ordre. Bloqué par le manifeste d’ADR-150 — décrire N services et leurs dépendances demande un artefact, pas un
--execrépété.✅ Bornes par défaut posées — le bloquant de publication est LEVÉ (2026-08-09)
Directive BDFL du 2026-08-09 : « il faut impérativement mettre des valeurs à ces barrières avant que l’API soit publique. On ne laisse pas sortir nos versions sans limites. » Une borne « à décider plus tard » devient un défaut de fait dès la première version publiée, et plus personne ne la révise.
Ce qui est mesuré (speedy, 2026-08-09, service réel) :
air-sshdconsomme 4,00 tâches et ~819 Kio par connexion, exactement linéaire, sans fuite. Au repos : 1 tâche, 1,5 Mio.Ce qui n’est pas borné, et qui décide du chiffre : le plafond de concurrence (
DEFAULT_MAX_PREAUTH_TOTAL = 256) ne couvre que la fenêtre pré-auth — l’AdmissionGuardrend sa place au succès de l’authentification. Les connexions établies ne sont bornées par rien. Pire cas garanti :1 + 4×256 = 1025tâches (~205 Mio) ; au-delà, ouvert.Valeurs retenues (BDFL, 2026-08-09) :
pids.max = 2048etmemory.high = 512 Mio, appliquées par défaut — un service non borné n’existe plus. 2048 couvre les 256 connexions pré-auth (1 + 4×256 = 1025) plus ~256 sessions établies ; 512 Mio couvre plus de 600 connexions à 819 Kio.
memory.high, pasmemory.max: au-delà, le noyau freine et récupère — il ne tue pas. Un service qui fuit devient lent et visible, ce qui est ce qu’on veut d’une défense.Retirer une borne se demande explicitement (
--max-processes unlimited) :0est refusé, c’est une faute de frappe, jamais une intention.Reste ouvert, sans blocage : exprimer ces valeurs en fraction de la capacité (systemd :
DefaultTasksMax = 15 %dethreads-max) pour qu’elles suivent d’elles-mêmes du Raspberry Pi 4 au serveur.✅ Temps 3b du lanceur — les bornes — FAIT (2026-08-09)
pids.max(la bombe à fourche) et la comptabilité (pids.current,memory.current,memory.peak). Option--max-processes; aucune borne par défaut, la largeur des enveloppes n’étant pas tranchée (question ouverte n° 1 d’ADR-150).Ce que le chantier a appris. Poser une borne exige que le cgroup parent du service soit vide de processus. Le lanceur déménage donc dans un sous-cgroup dédié, ses services devenant ses frères. Écrire
+pidssur un cgroup peuplé est accepté par le noyau puis casse toute migration vers ses enfants — une API qui, appelée depuis un terminal, rendait le cgroup de la session inutilisable. Elle refuse maintenant avant d’écrire.Les bornes sont ÉPROUVÉES (contrairement au confinement du 3a) : sous
systemd-run --user --scope, aucun test ne saute. Dans un terminal ordinaire, ils se sautent en le disant.Reste du temps 3 : plusieurs services — dépendances, ordre, redémarrage (3c).
✅ Temps 3a du lanceur — le cgroup — FAIT (2026-08-09)
AirCgroupManager(crate couche 1 nouvelle,air-cgroup) : le lanceur confine son service dans un cgroup, et n’y laisse rien vivre après l’arrêt. C’est la seule frontière que le noyau tienne — un groupe de processus est une convention qu’unsetsidquitte, un cgroup non.Pas un descellement de couche 0 : les cgroups se pilotent entièrement par le système de fichiers. Un seul additif c1 l’accompagne (
AirCommand::join_cgroup, 14ᵉ), qui fait rejoindre le cgroup à l’enfant, avant sonexecve— donc avant qu’il ait pu forker, ce dont dépend entièrementcgroup.kill.Deux leçons de la journée, toutes deux trouvées par l’exécution : le cgroup n’est pas un dernier marteau mais une clôture (le premier jet ne tuait l’arbre que si le service refusait de mourir — donc jamais dans le cas courant) ; et un nom de cgroup dérivé du seul PID du lanceur aurait fait tourner un second service sans frontière.
Reste du temps 3 : les bornes (
pids.maxcontre la bombe à fourche,memory.max), la comptabilité, puis plusieurs services (dépendances, ordre, redémarrage).✅ A-2 du lanceur — additif couche 1 — FAIT (2026-08-09)
air-launchdne visait que son enfant direct : un service quiforklaissait des fils vivants avec la copie héritée de l’écoute — port occupé, redémarrage impossible, processus non supervisés. C’était la limite qui comptait pour un opérateur.Le service est désormais lancé chef de sa session (
AirCommand::new_session, 13ᵉ additif couche 1) : il est chef de son groupe, tout ce qu’ilforky naît, et l’arrêt vise le groupe. L’additif est un découplage —setsidexistait déjà dans la crate, mais lié àlogin_terminal, qui exige un PTY et écrase les trois flux standard.Sûreté du ciblage : un groupe se désigne par le PID de son chef, et ce PID ne peut pas être recyclé tant que le lanceur n’a pas récolté l’enfant (il tient son
pidfd). Le groupe visé est le sien ou plus rien (ESRCH⇒ repli sur lepidfd) — jamais celui du lanceur.Ce qui reste au temps 3 : un petit-fils qui appelle
setsidà son tour quitte le groupe. La frontière que le noyau tient, c’est le cgroup.✅
make_directory_all— additif couche 1 — FAIT (2026-08-09)Le seul vrai downgrade qu’ait trouvé la revue adverse de l’épinglage est fermé : sous
umask 0,std::fs::create_dir_allfaisait naître~/.config/air/en 0777. Qui peut écrire ce répertoire peut y remplacer le magasin d’hôtes connus, donc décider à quelles machines son propriétaire se connecte. Désormais 0700, quel que soit l’umask.
AirFileSystem::make_directory_all(path, mode)— 12ᵉ additif couche 1 non scellé (inventaire). Chaque répertoire créé reçoit son mode par unfchmodat2no-follow après lemkdirat; ce qui existait déjà n’est pas retouché. Alternative écartée :umask(0)autour de la création — état process-global, donc une course échangée contre une dépendance à l’environnement.Au passage, une note de suivi corrigée : elle affirmait qu’
air-filesystemn’exposait aucune création de répertoire à mode explicite. C’était faux —make_directoryexiste. Ce qui manquait était la forme récursive.✅
air-ssh known-hosts— FAIT (2026-08-09)Un utilisateur ne pouvait pas ajouter un hôte dans son propre magasin sans le mot de passe de root : le gate d’ADR-101 s’applique à
air-keystore known-hosts add|removeavant de regarder--store. Pire, un compte ordinaire échoue même avant le gate (il ne peut pas lire/etc/air/shadow.aircfg), et contourner parsudocasse le magasin — le fichier renaît possédé par root, que la garde de lecture refuse ensuite.Décision BDFL : aucun gate relâché. Le verbe
air-ssh known-hosts add|removeécrit le magasin personnel sous l’identité de son propriétaire ;air-keystoregarde le magasin administré derrière son gate. Chaque magasin est derrière l’outil qui a l’identité qu’il faut. Au passage, le message de refus « la clé a changé » cesse de conseiller une commande que son destinataire ne peut pas exécuter.✅ La demi-fermeture ne ferme plus le canal — FAIT (2026-08-08)
Le tunnel inverse concurrent perdait des connexions, silencieusement et seulement sur certaines machines — donc invisible en CI. Ce n’était pas une course : les trois pompes serveur émettaient
CHANNEL_EOFetCHANNEL_CLOSEdès qu’une lecture rendait0, c’est-à-dire sur une simple demi-fermeture — le geste normal de tout protocole requête/réponse. RFC 4254 §5.3 réserve le second à la fin du canal ; le canal disparaissait donc avant la réponse.Corrigé aux trois endroits :
forward_blocking(pilote privsep, livré),forward_pump(-Lmonolithique) ettcpip_forward(-Rmonolithique). Le client, lui, appliquait déjà la bonne règle depuis toujours (client_forward::finish_pump) — ce sont les pompes serveur qui avaient divergé. Quatre tests existants encodaient le défaut ; trois nouveaux le prouvent mordant, dont deux face à un vrai client OpenSSH (le nôtre est trop tolérant pour le voir).✅ Épinglage des clés d’hôte nues — FAIT (2026-08-07)
air-sshn’épinglait aucune clé d’hôte nue : sans magasin de CA d’hôtes configuré — le cas nominal — il acceptait n’importe quel serveur. Le verdictHostTrust::PlainKeyrenvoyait à un cheminknown_hostsqui n’existait pas, et les deux appelants de production le jetaient. Le socle (air-keystore::hosts, le schéma, le programmeair-keystore known-hosts) existait déjà en entier : il n’avait jamais été câblé.Désormais : deux magasins, l’administré de la machine (chemin fixe, lecture seule, verdict prioritaire) puis le personnel du compte (
knownHostsPath, écrit à la première vue). Clé changée ou révoquée ⇒ refus sans appel, pendant la poignée de main. Hôte jamais vu ⇒ empreinte affichée et accord explicite exigé sur un terminal, refus à défaut. Chaque saut d’un rebond est épinglé sous son propre motif.Ce qui n’est pas garanti, et qui est écrit partout comme tel : la première connexion reste non protégée — limite du modèle, pas reste de travail. L’acquis est la détection du changement de clé. Clôture consignée dans ADR-118 §A6 (FR + EN).
🔵 CAMPAGNE DOCTRINE & CHEMIN v1.0 (2026-08-03 → 08-04) — REPRENDRE ICI. Deux journées de doctrine, d’audit de conformité et de cadrage. Tout est mergé ; ce qui suit est l’état des décisions et des blocages, dans l’ordre où ils commandent la suite.
Ce qui a été gravé. ADR-149 (architecture de code : contrôleurs, managers, contrat d’API, déclaration
role, convention// SECOND-PATH:) — ADR-150 (manifeste demande / machine octroie, artefacts binaires, ordre d’arbitrage signature → octroi → enveloppe, refus muet, deux portes distinctes) — l’amendement D6 d’ADR-046 (plus d’exécution non confinée ; PID 1 seule exception ; refus par défaut) — le Principe 10 (cloisonnement) et le Principe 11 (entrée stricte, sortie lisible) — et l’état de conformité des Managers dans l’amendement d’ADR-077.Décision de placement (2026-08-04) :
air-launchdest en COUCHE 2, requis dès la v1.0. Cadrage et découpage en trois temps :notes/air-launchd-couche2-roadmap-fr.md.✅ Temps 1 du lanceur — FAIT (2026-08-04), critère de sortie atteint
air-launchdexiste et tourne — sur la pilelinux-air, les deux arches. Une seule ligne d’air-sshda changé :SystemdHost::from_env()→air_service::detect(). Le test d’intégration et le fuzz tournent sur la pile hôte gnu et ne valident que la logique ; la preuve qui engage est on-target, enstatic-piesans libc C :air-launchda lancé le témoin etair-sshdsur x86_64 (carbon) et sur aarch64, vrai Raspberry Pi 4 (raspi-srv-1) — écoute remise parSCM_RIGHTSet comptée par le service,READYreçu sur le canal (aucune variable d’environnement posée), bannièreSSH-2.0-Air_0.7.0servie sur le socket hérité, arrêt honoré. Détail et réserves :specs/layer-2/air-launchd.md§6.Le contrat d’activation est Air, pas systemd (décision BDFL 2026-08-04) : un
SOCK_SEQPACKETsur le descripteur 3, cinq enregistrements à disposition fixe et bornée (air-launch-proto, cœur pur sans-IO, fuzzé 11,1 M exécutions), écoutes remises parSCM_RIGHTS, disponibilité et ordre d’arrêt sur le même canal. Aucune variable d’environnement — là où systemd en fait parser trois (Principe 11). Le nombre d’écoutes n’est pas annoncé, il est constaté : le noyau fait foi, pas le pair. Spec :specs/layer-2/air-launchd.md.Un piège fermé, trouvé par l’exécution et non par la revue : le masque de signaux d’un superviseur est hérité à travers
forkETexecve;air-sshddémarrait doncSIGTERMbloqué et ne mourait qu’auSIGKILL. D’où l’additif couche 1 ADR-151 (AirCommand::inherit_fd
clear_signal_mask). Aucun tag posé : ces deux additifs rejoignent l’inventaire collectif (10ᵉ et 11ᵉ), le re-sceau restant unique à la complétion d’air-ssh/air-sshd.Durcissement de la supervision (2026-08-06, revue adverse de la pile). Le pilote traitait un refus du cœur comme une raison de mourir, en lâchant son enfant : huit octets de charabia sur le canal et le service se soustrayait à sa supervision — orphelin réparenté à
init, seul détenteur de l’écoute, donc redémarrage impossible (EADDRINUSE, le lanceur ne pose pasSO_REUSEADDR). Corrigé sur les trois plans, spec §4.4/§4.5 : (1) un refus produit unReport::ProtocolViolationet enchaîne sur la voie d’arrêt — le lanceur sort proprement, ayant arrêté ce qu’il supervisait (les deux sites, lecture nominale et drainage) ; (2)LaunchedServiceporte unDropqui tue et récolte si la fin n’a pas été constatée — l’invariant devient vrai par construction, y compris sur panique ; (3)PR_SET_PDEATHSIG(SIGKILL)sur l’enfant, pour le cas où le lanceur ne vit plus. Les trois correctifs sont prouvés « mordants » (test retiré → rouge). Fermé au passage : la troncature silencieuse du seqpacket (tampon àMAX_RECORD_LEN + 1des deux côtés) — la spec §2.5 promettait « aucune tolérance », ce qui était faux sur ce chemin.🔧 Outillage de l’architecture — FAIT (2026-08-05)
ADR-152 :
cargo xtask check-controleurs. Les 83 crates déclarent leurrole— vocabulaire arrêté par le BDFL le 2026-08-05 : 28bridge, 18manager, 16controleur, 10vocabulaire, 4service, 3user-cli, 3runtime, 1syscall. Huit règles vérifiées, dont « un bridge n’accède jamais à un contrôleur », « seule la couche 1 consomme les syscalls » et « ni un démon ni un outil n’expose d’API réutilisable ». Cliquet surxtask/controleurs-reference.toml: 28 écarts inventoriés, nouveaux signalés, orphelines dénoncées. Consultatif dans la barrière et en CI ; bloquant quand le registre sera vide.📋 Les 26 écarts = la matière de l’ADR de reprise (prochaine tâche)
Famille Nombre Ce qu’il faut faire manager-absent10 écrire le manager dû ( air-uringest le moins cher : 0 opération libre)bridge-vers-controleur7 rebrancher le pont sur un manager — 2 sont intra-couche 2 controleur-fuite4 manager à écrire, ou consommateur de couche 2 à corriger vocabulaire-impur3 sortir les opérations du vocabulaire syscall-hors-couche-12 air-libc-capi(advisory ADR-087) etair-libc-spawn(dev seulement)service-consomme2 air-agent(lié parair-sshd, livrée — extraire la bibliothèque de protocole) etair-launchd(dev seulement)Hors gate, à trancher dans l’ADR de reprise : le découpage d’
air-sshd. La crate produit deux binaires de natures opposées —air-sshd(démon) etair-ssh(outil utilisateur) — plus unelibde 96 opérations libres. Orrolese déclare par crate : aucune valeur ne peut être vraie tant qu’elle porte les deux. ADR-095 avait déjà tranché « deux binaires séparés » pour le packaging ; la taxonomie le redit depuis l’architecture.Reste à décider : quand basculer le gate en
--bloquant.➡️ La prochaine tâche
La cage est posée (temps 2, fait le 2026-08-10). La suite, dans l’ordre arrêté avec le BDFL le même jour :
(0) ✅ Les façades de couche — FAIT (2026-08-11, #669).
air-layer-0/1/2: trois crates vides qui déclarent ce qu’est une couche (ses crates, et les features nécessaires pour la mesurer isolément). Elles remplacent la table de features de #665 — la déclaration vit désormais dans un manifeste relu en revue.cargo xtask check-facades, bloquant, garantit que façade etlayer = Ncoïncident : la duplication est vérifiée, pas latente. Rôlefacadeajouté au vocabulaire ; deux règles d’architecture et le générateur d’index amendés (une façade ne consomme rien et ne compte pas parmi les crates d’une couche).(a) ✅ Les profils permissifs TRANSITOIRES — FAIT (2026-08-10). Catégorie
TransitionalGlibc, établie par la mesure (strace -csursh/ls/sed/mkdir/cat/grep+ un pipeline complet), demandée par--profil-transitoire-glibcet jamais dérivée d’un octroi. Un test garantit qu’aucune de ses 17 variantes n’a fui ailleurs : la suppression en v1.0 est donc vérifiée, pas promise. Coût assumé et écrit :ioctl,clone,vfork.(b) ➡️ PROCHAINE TÂCHE : distribution de la capability AirCom par le lanceur (qui donne du même coup son applicateur à la famille
aircomet supprime le danger de réutilisation du pid dansair-registry, le lanceur détenant unpidfd), puis l’instruction des deux rouges intermittents (remote_forward,keepalive), puis la reprise de V2.6.Reste ouvert sur le temps 1, par ordre d’intérêt :
- Le lanceur ne sait pas arrêter un service qui
fork(spec §4.5). Il ne vise que l’enfant direct ;PDEATHSIGne s’hérite pas aux petits-enfants. Unair-sshdquiforkses sessions laisse des fils vivants avec la copie héritée de l’écoute : port occupé, redémarrage impossible, processus non supervisés en vie. C’est la limite qui compte pour un opérateur, et son correctif complet est le cgroup du temps 3. Demi-mesure écartée :setsidsur l’enfant +kill(-pgid)demanderait d’exposersetsidsans terminal de contrôle surAirCommand— un additif couche 1 hors de celui qu’ADR-151 a autorisé (voir « dettes » plus bas).- Le
deadline_msduStopn’est pas exposé au service (il ne sait pas combien de temps il a pour draîner).air-sshdn’arme pas sa source d’arrêt, donc il cède auSIGTERMd’insistance au lieu de draîner — occasion manquée, pas défaut du contrat.- Un seul service par lanceur.
- Flot de
Statusnon borné hors drainage. Un service en boucle sature le journal du lanceur. Le contenu est borné et anti-injection (validé UTF-8, sans caractère de contrôle) ; c’est un DoS de volume seulement, et il vise le journal, pas le lanceur. À refermer par une limite de débit quand le journal structuré arrivera.✅ Le blocage qui commandait beaucoup — LEVÉ (2026-08-10)
La largeur des enveloppes par défaut (question ouverte n° 1 d’ADR-150) est tranchée : enveloppes filesystem écrites, familles sans applicateur vides par défaut — un octroi qui les invoque est refusé. Le mécanisme de cage existait déjà (ADR-112, fait, en production dans
air-sshd) ; ce qui manquait était de savoir quoi poser, et on le sait.Ce qui suit reste la trace de l’instruction qui a mené à la décision — conservée parce qu’elle porte un prérequis toujours ouvert (le portail), pas parce que quelque chose est encore bloqué.
Piste instruite : un plancher « exister et rendre compte », des incréments grossiers et nommés,
fork/exechors plancher sauf incrément « outils de développement et shells » (tranché BDFL). Et un prérequis découvert, toujours ouvert : le portail — l’utilisateur désigne, un service de confiance rend un descripteur — sans lequel toute application demandera un accès fichier large et le modèle d’octroi se dégradera. Air n’a de portail que pour les applications étrangères (xdg-desktop-portal-air, exception D-Bus d’ADR-001), aucun pour les siennes.📌 Autres chantiers instruits, prêts à démarrer
- AirCom, surface Rust sûre par défaut —
notes/aircom-surface-rust-sure-conception-fr.md. Le transport tient (~11 200 lignes, SEQPACKET, capabilities parSCM_RIGHTSasync) ; c’est la surface qui manque.AirValueOwnedexiste et n’est utilisé nulle part ;#[air_class]ne génère pas encore les méthodes (AO.4). Cinq blocsunsafedans les deux exemplesair-notifyd. Critère mécanique :grep -c unsaferend0.- Simplification du rendez-vous AirCom : le
total_lentransmis est redondant — les valeurs qui font foi sont dans l’en-tête du segment. Le dériver du descripteur supprime un message et une hypothèse de confiance.- Exemples de référence :
crates/air-com/examples/meteo_{station,console}.rs— 34 et 23 lignes, zérounsafe, exécutés sur carbon. À faire évoluer quand la couche 2 sera complète.AirCgroupManager(temps 3 du lanceur) : n’existe pas. Pas un descellement — les cgroups se pilotent par le système de fichiers.⚠️ Dettes ouvertes, à ne pas perdre
AirCommand::new_session()— additif couche 1 à demander (2026-08-06). Exposersetsidsans terminal de contrôle est ce qui manque pour que le lanceur puisse signaler le groupe de son service plutôt que son seul enfant direct (spec §4.5). Aujourd’huisetsidn’est atteignable, sur le cheminexec, que parlogin_terminal(slave)— qui exige un PTY et écrase les trois flux standard.air_signal::send_signal_to_process_groupexiste déjà : c’est la pose du groupe qui manque, pas le tir.air_process::spawn_processsait le faire (set_session,SpawnProcessGroup::Own) mais rend unPidnu, sanspidfd. Arbitrage BDFL requis (l’inventaire couche 1 est gelé au-delà d’ADR-151) — ou bien la question tombe avec le cgroup du temps 3, qui est la vraie réponse.unsafeen couche 2 dansair-service: 5 blocs (2026-08-06). Les 3 existants, plus les 2 d’AirLaunchdHost::from_descriptor(BorrowedFd::borrow_rawde sonde, puisOwnedFd::from_raw_fdd’adoption du descripteur 3). Les// SAFETY:sont corrects et un// SECOND-PATH:(ADR-149 D10) est posé au point d’usage, mais ADR-149 D2 veut l’unsafeen couche 0 et nulle part ailleurs. La bonne porte est une primitive couche 1adopt_inherited_fd(sonder, puis rendre unOwnedFdou rien) — non écrite, l’inventaire couche 1 étant gelé. À rouvrir avec l’additif ci-dessus.- Planchers de branches absolus restants (2026-08-05). Le plancher 78 du mode COMPLET est remplacé par une non-régression contre
xtask/couverture-reference.toml(référence relevée en CI, tolérance 0.10, ratchet à +0.25) : il avait dérivé au-dessus de toute mesure réelle —mainlui-même mesurait 77,42 % en local et 77,72 % en CI — et bloquait donc toutes les PR. Restent sur un78absolu et non calibré : la barrière locale (xtask/src/barrier.rs) et le mode--layer 0|1. Chacun demande sa propre référence (les périmètres ne sont pas comparables) ; à traiter séparément. Cf.docs/CI.md§ « Branches : non-régression ».- ADR-118 — le volet CLIENT n’existe nulle part (constat du 2026-08-04, au moment de passer l’ADR en
fait). Les 8 incréments mergés sont tous serveur ; le §4 (options clientIdentitiesOnly,ForwardAgent,CanonicalizeHostname,ServerAliveInterval) et le §2 côté client (blocsMatch) n’ont aucune ligne de code :ssh-config.capnpne porte aucun de ces champs, et-An’est qu’un drapeau de ligne de commande (#627), pas une option de configuration. Ces options avaient été rattachées à ADR-116 — qui a été clos sans elles. Soit un incrément dédié (extension additive deSshConfig/HostBlock), soit un arbitrage explicite de mise hors périmètre.IdentitiesOnlya une vraie conséquence : sans lui, le client épuise le plafond de 6 essais (ADR-094).- ADR-118 — le volet CLIENT : ouvert le 2026-08-04, CLOS le 2026-08-05 (c1, c2, c3). Le constat de la veille (« aucune ligne de code ») est entièrement levé.
- ✅ c1 —
ForwardAgentpar hôte +-a:HostBlock.forwardAgenten union à trois états (unspecified/yes/no,unspecified= le zéro capnp ⇒ rétro-compat prouvée),SUPPORTED_VERSIONinchangé à1; drapeau-a/--no-forward-agentet précédence-a>-A> config > défaut fermé (-A+-a⇒ erreur) ; CLIconfig set forward-agent yes|no|unset --host <motif>+ JSON à trois états ; e2e réel contresshdd’OpenSSH. Guide :guides/air-ssh-config-client-fr.md. Fast-follow (revue adverse, avant merge) : garde de propriété/permissions surssh.aircfg— lecture enO_NOFOLLOW+statxpar descripteur, refus si le fichier n’appartient pas à l’uid effectif, s’il est inscriptible groupe/autres (0644passe), ou s’il s’agit d’un lien symbolique ; écriture en0600parwrite_atomic_with_mode. Précédent OpenSSH (« Bad owner or permissions »), et le fichier désigne aussi les magasins de révocation d’hôte (ADR-109/138) — la garde valait donc déjà avant-A,ForwardAgentl’a rendue exigible. Plus : l’allumage par la config n’est plus muet (une lignestderrnommant le bloc, rien quand-Aest explicite, rien quand aucun relais n’est demandé). Fast-follow n°2 (2e revue adverse, avant merge) — le défaut majeur n’était pas dans ce que la garde contrôle mais dans ce qu’elle n’atteignait jamais :O_NOFOLLOWne dit rien du type de l’entrée, etopen(O_RDONLY)sur un FIFO bloque indéfiniment ⇒ unmkfifoposé par le tiers du scénario ci-dessus figeait chaqueair-sshde la victime, avant toute vérification. Corrigé par le duoO_NONBLOCK(pour atteindre la garde) + refus du type non régulier (pour décider) — l’un ne suffit pas sans l’autre — avec un test à attente bornée (une régression échoue, elle ne fait plus pendre la CI). Plus :Err(_) => Ok(None)discriminé (ENOENT/ENOTDIR= pas de config ; tout le reste = refus, comme la docstring le promettait) ; couple (motif, bloc) deForwardAgentdérivé du bloc (zip) au lieu d’ununwrap_or_default()qui fabriquait le verdict que le type devait rendre impossible ; lecture fail-closed sur un compte rendu incohérent du noyau.- ✅ c2 —
ServerAliveInterval/ServerAliveCountMax(2026-08-05, branchefeat/cfgclient-c2-server-alive) : détection de serveur mort sur les deux moteurs clients (shell/exec et moteur-N/-L/-R, celui qui en a le plus besoin). La décision est partagée avec le serveur —KeepalivePolicy/KeepaliveTracker::stepdecrate::keepalive, écrits une fois, employés aux quatre points d’intégration : il n’y a pas deux sémantiques de keepalive dans le dépôt. Seul l’ordonnanceur diffère, et l’ADR-118 §5 dit vrai côté client : le client construit un vraiair_async::Runtime, le keepalive y est une future de plus sur unTimeoutio_uring — là où le worker serveur, sans réacteur (seccomp sansio_uring_*), n’avait que l’échéance de sonppoll. Ce n’est pas une divergence de style mais la conséquence de l’endroit où chacun vit ; dit dans les deux modules et dans le guide. Modèle : défaut du poste (SshConfig) et surcharge par hôte (HostBlock), composés champ par champ (un bloc peut raccourcir le seul intervalle sans figer le compteur du poste). Deux portées, là oùForwardAgentn’en a qu’une — parce qu’un keepalive n’ouvre et ne ferme aucun accès : il ne fait que borner le temps qu’une connexion morte occupe des ressources. Précédence--server-alive-*> bloc > poste > désactivé ;--server-alive-interval 0éteint ce que la config allume. Piège du zéro capnp, traité deux fois : l’intervalle du poste est unUInt32nu (son0est le défaut), mais le compteur est une unionabsent/count(absent= le zéro du discriminant ⇒ 3, jamais0) et les surcharges de bloc en sont aussi (inherit/valeur — sans quoi un0de bloc, qui éteint, serait indistinguable du silence d’un bloc hérité). Uncount(0)écrit fait refuser l’artefact entier, aux deux portées et inconditionnellement (côté client les deux réglages vivent dans deux portées qui se composent : un0posé ici devient actif dès que l’autre portée fournit un intervalle). ⚠️ Le défaut que seule l’interop réelle a trouvé :air-sshdémet ses sondes en requête globale, mais OpenSSH les émet en requête de canal dès qu’un canal est ouvert (client_alive_check():channel_find_open()d’abord). Toutes les suites contre pair scripté passaient — elles étaient fidèles à notre serveur — et le test contre un vraisshdéchouait : unair-sshface à unsshddurci se faisait couper dès qu’une session existait. Les deux formes sont désormais honorées (CHANNEL_FAILURE/REQUEST_FAILURE, RFC 4254 §5.4 et §4), sur les deux moteurs, quel que soit le réglage — répondre n’est pas une option. Les deux tests d’interop sont séparés par sens : mis en tension sur la même connexion, ils passaient aussi avec un client muet (les sondes de l’un sont du trafic reçu par l’autre, ce qui remet son compteur à zéro) — vérifié par mutation. Revue adverse avant merge — quatre correctifs, un résidu nommé. (a) La file des requêtes globales du moteur-Navait d’abord été marquée pour y faire figurer les sondes ; c’était une erreur, défaite : une FIFO apparie par position, donc le marquage n’apportait rien qu’un pair conforme n’obtenait déjà (les sondes partent après tous les-R, leurs réponses dépilent une file vide), et il coûtait une croissance non bornée — une sonde n’était dépilée que par une réponse de requête globale, alors que le compteur du keepalive, lui, est remis à zéro par tout paquet reçu ; un pair qui parle sans jamais répondre aux requêtes globales faisait croître la file d’une entrée par intervalle. (b)ETIMEDOUTne se diagnostique plus en « pair mort » sur la foi du seul code d’erreur : leconnect(2)d’un hôte absorbé par un pare-feu rend le même, et le message mentait sur le chemin par défaut (« 3 sondes … ServerAliveInterval 0 s ») ; la distinction est portée par un type (ConnectOutcome), produit au seul endroit où l’information existe. (c) Réponse de canal restreinte aux canaux confirmés — sur un-Lnon confirmé,remote_idvaut encore0et la trame serait adressée au canal 0 du pair. (d)engine.pendingvidée au dénouement à chaud : les pompes en attente d’intégration détiennent unRc<Engine>que l’Enginedétient en retour — cycle inatteignable par la sortie normale (qui exigependingvide), mais pas par celle-ci. Seconde revue adverse (PASS avec réserves) — cinq points, quatre corrigés, deux dettes consignées. (1) Le cycleRcavait un jumeau côté session, non corrigé, et le commentaire affirmait le contraire :AgentRelay::pendingcontient elle aussi des pompes qui détiennent le relais qui les détient, et aucune des trois sorties de la boucle de session ne repassait par sontake_pending— un canalauth-agentouvert au tour précédent faisait fuir le relais, ses canaux et les fd de socket d’agent. Vidage après la boucle, ce qui ferme du même geste la sortieTeardownDeadline, pré-existante et atteinte du même défaut. (2) Un test documentait encore le design révoqué au correctif (a) — dernières occurrences dePendingGlobaldu dépôt : il annonçait une file « marquée » là où l’appariement est positionnel et repose sur l’ordre que RFC 4254 §4 impose au pair. (3)keepalive_interop.rspromettait une borne dure (timeoutexterne) qu’il n’appliquait pas :air-sshy courait dans unCommand::output()nu, donc pouvait immobiliser la suite — c’est désormais un vraitimeout(1), dont le code 124 est un échec nommé. (4) Lesshdréel de cette même suite n’était tué qu’au chemin nominal : unassert!rouge laissait un démon en écoute et unauthorized_keysdans/tmpsur le runner — jeton RAIISshdGuard. (5)KeepalivePolicydevient un type analysé (Principe 4) : champs privés,try_newfaillible pour seul constructeur, accesseurs, et les trois lecteurs s’y ramènent. Uncount_max = 0sous un intervalle actif rendDisconnectà la première échéance, sans avoir jamais sondé ; la règle vivait aux trois sites d’appel, jamais dans le type, et c2 élargissait la surface de trois fonctions publiques de plus. Le comportement ne change pour aucun couple ni aucun message ; le prix visible est trois bras d’erreur inatteignables, marqués COVERAGE (STRUCTURAL, ADR-035).- ⚠️ DETTE (classe pré-existante) : l’
Outboxdu client n’est pas bornée (client_forward.rs). c2 ajoute deux chemins où un message du serveur provoque une émission (réponse aux requêtes globales et de canal, sur les deux moteurs) : un serveur qui inonde deCHANNEL_REQUEST want_reply=truesans jamais lire fait croître la file de sortie. Le facteur d’amplification est inférieur à 1 (≈ 9 octets émis pour ≈ 20 reçus) et la classe est antérieure à l’incrément (le même chemin existe surmainpour les ouvertures de canal). Le plafond appartient à l’Outboxelle-même, pas à chacun de ses appelants : c’est un ticket propre, pas un effet de bord d’incrément.- ⚠️ DETTE (héritée de c1) : la fixture « artefact pré-c1 » n’est pas datée.
ssh_config/tests.rs::build_pre_c1emploie le constructeur courant : le message porte ledataWordCountd’aujourd’hui (2 mots), pas celui d’un artefact réellement écrit avant l’incrément (1 mot). La propriété tient — Cap’n Proto rend le zéro d’un champ hors section comme d’un champ absent, et c’est ce zéro que le lecteur doit lire eninherit— mais la preuve porte sur la sémantique, pas sur les octets d’hier. La doc du test le dit désormais ; la preuve par octets demanderait une fixture figée en dur.- ⚠️ RÉSIDU NOMMÉ (serveur, hors volet client) : le démon MONOLITHIQUE ne répond pas aux sondes d’un client. Une fois le shell ou l’
execlancé, plus personne n’y dépouille les messages du client (interactive::channel_to_masterignore les requêtes globales ; le cheminexecne lit rien pendant l’exécution). Unair-sshavecServerAliveIntervalface à unair-sshdsans--privsepinactif conclut donc à un pair mort à tort. Ce n’est pas une régression (aucun client Air n’émettait de sonde avant c2) et c’est cohérent avec la posture existante — ce même démon refuse déjà de démarrer avec unClientAliveInterval, son flux io_uring n’ayant pas d’attente bornée : le mode monolithique ne supporte pas le keepalive, dans un sens comme dans l’autre. Écrit dans le module et dans le guide plutôt que laissé à découvrir. Le corriger exige de donner un émetteur à la direction lectrice d’interactive(aujourd’hui une simpleRecvHalf, la règle « un seul écrivain » réservant l’émission à l’autre direction) et de faire lire le cheminexecpendant l’exécution : un chantier serveur à part entière.
- ✅ c3 —
ProxyJump(2026-08-05, branchefeat/cfgclient-c3-proxyjump) — dernier incrément du volet client, campagne close.air-sshatteint une machine derrière un bastion sans y déposer de clé privée. Rebond purement interne : aucun processus lancé, aucune commande, aucunexec— c’est ce qui le rend acceptable là oùProxyCommandne l’est pas.
- La couture existait : le client est générique sur son transport, donc
ProxyJumpest une quatrième implémentation deSshByteStream(JumpStream,crate::jump_channel) adossée à un canaldirect-tcpip. Handshake, confiance d’hôte, auth, session,-L/-R, agent et keepalive tournent au-dessus sans une ligne de modification —client.rsetclient_forward.rssont intacts.- Le point dur était le pompage concurrent : un constructeur séquentiel plus un lecteur et un écrivain par étage dans un seul
Unordered(ADR-098), pompes intégrées à chaud par le motif sentinelleNotifydeclient_forward. Le premier étage est sorti de la boucle : deux instanciations seulement (socket, puisJumpStream), donc aucune récursion générique à boxer.- ⚠️ Contrôle de flux — la famille de défauts déjà corrigée deux fois ici. Crédit rendu à mesure que le transport intérieur consomme (pas à l’arrivée),
max_packetopposé en réception et en émission. Prouvé par 3 Mio à travers le rebond, in-process et contre unsshdd’OpenSSH en bastion ; vérifié par mutation (sans recrédit : cale à 2 068 992 o contre Air, se fige contresshd, qui applique vraiment la fenêtre annoncée).- Le rebond ne dispense de rien : chaque saut vérifie sa propre clé d’hôte (ADR-109/137, magasins de la config) et s’authentifie séparément (compte et clé du bloc du saut ; à défaut, l’identité de l’invocation — jamais
default-identityseule, qui ferait gagner un fichier contre un-i). Testé aux deux étages.- Aucun repli direct, testé sur un montage qui pourrait tricher : cible joignable en direct, saut mort, la commande ne s’exécute pas.
- Chaîne bornée à 8 sauts (développement transitif compris) et cycle refusé — avant tout octet de réseau. ⚠️ Faux positif levé par le bout en bout : le cycle traqué est celui du graphe de configuration, pas une coïncidence de noms (
-J 127.0.0.1:2222vers127.0.0.1:2200est légitime) ; tous les tests unitaires passaient, ce sont les binaires livrés qui l’ont trouvé.- Modèle :
HostBlock.proxyJump @12 :List(JumpHost), liste structurée et non une chaîne à re-parser (Principe 11 — la CLI compileuser@host:port). Aucune union, à la différence de c1/c2 : pas de défaut global dont un bloc devrait se démarquer, donc la liste vide est le silence et un artefact pré-c3 se relit à l’octet près.-J [user@]hôte[:port][,…]répétable ;-J noneannule (parité OpenSSH). Corollaire assumé : avec-J, la cible n’est plus résolue localement — c’est le dernier saut qui la résout.- Aucun additif couches 0/1.
ECONNREFUSEDaurait été le mot juste pour un canal refusé ; absent de la tableErrnoscellée, on rendECONNRESETet le diagnostic utilisateur nomme le saut et la raison.- ⚠️ FAST-FOLLOW c3 (revue adverse du 2026-08-05) — une revendication de sécurité RECTIFIÉE, huit mineurs corrigés. La revue a reproduit la preuve de contrôle de flux à l’octet près (2 068 992 o sur 3 145 728 sans recrédit) et validé l’absence de repli, les cycles, le cloisonnement des étages et l’additivité du schéma. Ce qu’elle a trouvé et ce qui a été fait :
- La revendication qui ne tenait pas. « Chaque saut vérifie sa propre clé d’hôte » se lisait comme une protection contre un bastion compromis. Elle n’en est pas une :
evaluate_host_keyrendHostTrust::PlainKeydevant une clé d’hôte nue, ce verdict n’a aucun consommateur (known_hosts/TOFU non implémenté) et il est jeté au point d’appel. Sans magasin de CA d’hôtes,air-sshaccepte n’importe quelle clé d’hôte — sur un rebond comme en direct. Manque pré-existant et global, pas introduit par c3. Arbitrage BDFL : corriger les TEXTES (module, schéma, guide,statuts.toml, aide de-J), pas durcir-J— le durcir singulariserait le rebond pour une faiblesse globale et rendrait la fonctionnalité inutilisable partout où aucune CA n’est déployée. Le vrai correctif est le chantier TOFU/known_hosts, cap suivant. — ✅ FAIT le 2026-08-07, voir l’encart « épinglage des clés d’hôte nues » en tête de ce document, et la clôture d’ADR-118 §A6.- m1 — l’identité de saut repliait sur la clé de la CIBLE.
config.identityporte l’identitydu bloc cible ; unHost prod / identity …/prod-only-key / proxyJump bastionprésentait donc la clé dédiée àprodau bastion. Comportement corrigé :Config::jump_identity, résolue à part (-i/--key-seed/agent, sinon le défaut du poste), et calculée seulement si un saut en a besoin.- m3 — aucune échéance sur le montage de la chaîne. Un bastion qui accepte le TCP, complète KEX et auth puis ne répond jamais au
CHANNEL_OPENfigeaitair-sshsans borne (le keepaliveServerAlive*ne couvre que la session intérieure).STAGE_SETUP_TIMEOUT= 60 s par étage, couvrant les quatre moments à la fois.- m2/m4 —
jump_stagesrefuse au lieu de dégrader : compte de saut indéterminé (plus d’unwrap_or_default()qui partait avec unuservide sur le fil) et contrôle de longueur explicite au lieu d’unzipqui tronquerait la chaîne — une chaîne tronquée ferait ouvrir la session sur un bastion en croyant être sur la cible. Appelé avant la résolution d’adresse et la connexion (Principe 4).- m5/m7/m9 : l’échec d’encodage du
CHANNEL_OPENse propage (plus de canal à charge vide) ; le compte d’un saut est borné (MAX_JUMP_USER_LEN) à la lecture et à l’écriture, comme l’hôte — c’est un champ qui part sur le fil ;write_ssh_configrevalide ce qu’il sérialise et rend unResult.- m8 — chien de garde de test par PID. Le tueur polait
/proc/{pid}puiskill -9 {pid}: entre la moisson et le sondage suivant, le PID peut être recyclé. LeChildest désormais conservé (try_wait/kill), et chaque cas arme unWatchdogde processus comme dans les deux autres suites.- Couverture :
jump_channel.rsétait à 40,5 % de branches (le moteur n’est exercé que par des tests d’intégration, invisibles à la mesure--lib). Onze cas unitaires de plus, dont un lecteur d’étage piloté par un transport en clair qui éprouve message par message ce qu’un saut peut envoyer.- 📌 Dette consignée, non corrigée :
Outboxnon bornée (jump_channel.rs, réponses auxGLOBAL_REQUEST/CHANNEL_REQUESTdu pair). Un bastion hostile qui inonde tout en cessant de lire fait croître la mémoire du client. Motif pré-existant (client_forward,client— déjà consigné en c2), mais il vaut ici pour un pair explicitement moins fiable. Le plafond appartient à l’Outboxelle-même : ticket propre, pas un rustine dansjump_channel.- Informatif :
HostBlock/HostSettingsgagnent un champ publicproxy_jump⇒ rupture source pour un constructeur littéral externe (air-sshdest couche 2, non scellée : autorisé, mais noté). Idemwrite_ssh_config, qui rend désormais unResult.- 🚫
ProxyCommand— hors périmètre, en attente de son propre arbitrage BDFL. C’est une porte d’exécution pilotée par un fichier de configuration (amendement D6 d’ADR-046, ADR-150) : une commande arbitraire lancée sous votre compte au moment où vous tapezair-ssh. L’absence est consignée dans le schéma, le module et le guide, pour qu’elle se lise comme un choix et non comme un oubli. C’est le même motif qui a écarté le critèreexecdes blocsMatchclient.- 🚫 Trois refus argumentés, tranchés en c1 (détail dans le guide) :
IdentitiesOnlyest déjà vrai par construction — le constat des 6 essais (ADR-094) porte sur le client OpenSSH,air-sshn’offre jamais plus d’une identité et refuse devant un agent à ≥ 2 clés sans sélecteur (invariant verrouillé par un test qui le nomme) ;CanonicalizeHostnamemettrait une résolution DNS sur le chemin d’une décision de configuration (Principe 11) pour un gain nul tant que la correspondance de motif est exacte ; les blocsMatchclient ajouteraientexec, une porte d’exécution pilotée par un fichier de config, que refusent l’amendement D6 d’ADR-046 et ADR-150 — le modèle conditionnel client existe déjà et s’appelle le bloc par-hôte.- Garde de
ssh.aircfg— quatre réserves consignées, non corrigées (2e revue adverse du fast-follow c1, 2026-08-04). Aucune n’est exploitable par un tiers ; toutes sont des écarts entre ce que le code tient et ce qu’on aimerait qu’il tienne.
- Composants de répertoire non contrôlés.
O_NOFOLLOWne porte que sur le dernier composant : un lien symbolique sur~/.configou~/.config/airn’est pas vu. Le vrai correctif estResolveFlags::NO_SYMLINKS(crates/air-sys-types/src/fs.rs), qu’AirFile::openn’expose pas ⇒ additif couche 1, hors périmètre tant que l’inventaire est gelé. La propriété qui tient en attendant est le contrôle de propriétaire (un non-privilégié ne peut pas créer un fichier possédé par la victime) — parité OpenSSH, qui ne contrôle pas davantage les parents de~/.ssh/config. Consigné dans le guide.- Lecture non bornée (
bin/air-ssh.rs,read_to_end) : pas de plafond de taille, alors queAirFileSystem::read_to_bytes(path, Some(limite))est déjà employé pourauthorized_keysdans cette même crate. Non exploitable par un tiers (le fichier appartient à la victime, la garde vient de l’exiger) : auto-infligé. Le débordement du compte rendu du noyau, lui, est corrigé (fail-closed).- Octets de config rendus verbatim au terminal (
bin/air-ssh.rs, annonce du relais) : un motif de bloc peut porter CR/ANSI. Source semi-fiable — maisconfig importaccepte un JSON d’origine quelconque, donc la fiabilité n’est pas acquise.config export/get/setlisent encore parstd::fs::read, sans garde :config getaffiche une config queconnectrefuse. L’outil de diagnostic ment sur ce qui sera appliqué. Ni fuite ni écriture à travers un lien — mais à réconcilier.- Bornes de longueur de liste des lecteurs de config binaire (constat du 2026-08-04, au fuzz de c1 ; confirmé par la revue adverse du fast-follow c1) :
parse_ssh_configetparse_sshd_configitèrent une liste Cap’n Proto dont la longueur vient de l’artefact, avec lesReaderOptionspar défaut pour seule borne anti-DoS. Un artefact de 216 octets dont la longueur dehostsest mutée à 4 194 305 fait travailler le décodeur ~10 s. Côté client, la réserve tombe : aucun chemin réseau n’atteintparse_ssh_config, l’artefact est le fichier de l’utilisateur, etair-sshn’est pas setuid. Côté serveur, elle tient : unsshd.aircfgcorrompu peut faire consommer des centaines de Mio au démon au démarrage. Correctif d’une ligne (ReaderOptions::traversal_limit_in_wordsresserré), à poser d’un bloc pour les deux lecteurs, pas au fil des incréments.- JSON à clés dupliquées :
air-jsonretient la PREMIÈRE,jq/Python la dernière (constat de la revue adverse du fast-follow c1, 2026-08-04).AirJsonValue::get(crates/air-json/src/value.rs) fait un.find(): un objet{"forward_agent": false, "forward_agent": true}se litfalsepour Air ettruepour les outils du reste du monde. Un fichier d’import peut donc dire une chose au réviseur humain et une autre à Air, sur le réglage le plus dangereux du fichier. ⚠️ Ne pas corriger au fil de l’eau :air-jsonest en couche 1, et un changement de comportement y est une rupture, pas un additif — relève du re-sceau et d’une décision BDFL. Les deux issues plausibles : dernière occurrence (aligner sur le monde) ou refus du doublon (Principe 11 — la tolérance est la faille) ; la seconde a notre préférence.- Aucun gate CI ne prouve que le Rust généré correspond au schéma capnp (constat de la revue adverse du fast-follow c1, 2026-08-04) :
crates/air-sshd-schema/regenerate.shest manuel. La propriété de sécurité «unspecified= zéro du discriminant » vit dans le.rscommitté ; le.capnpn’est que de la documentation tant que rien ne les confronte. Correctif : un job qui exécuteregenerate.shpuisgit diff --exit-code.- Additifs couches 0/1 non scellés — inventaire tenu dans
notes/inventaire-additifs-couches-avant-re-sceau-fr.md: 9 additifs couche 1 depuiscouche-1-v3.8(tous additifs ⇒ mineurv3.9, pas le majeur d’ADR-123) et 1 additif couche 0 (fchmodat2, #568) resté descellé sans tag. À trancher au re-sceau unique de fin d’air-ssh.- ADR-149 D1 est faux pour la couche 5 : ADR-006 rend les couches 3 et 4 absentes en profil
air-base, donc « N n’invoque que N-1 » ne tient pas littéralement. Latent — aucune crate en couche 3, 4 ou 5 aujourd’hui.- ADR-005 (systemd) toujours contredit par sa propre mise en œuvre.
- ADR-046 D6 amendé, mais le sort des binaires étrangers reste à écrire dans le détail.
- Non conformités ADR-149 :
air-terminal(aucun contrôleur, 21 opérations),air-socket(moduleenginepublic),air-process(toute la séparation de privilèges hors contrôleur — le plus grave),air-signal, plusair-keystore/air-device/air-sandboxsans contrôleur. À mener en un seul cycle de descellement.- Gate
check-controleurs: à écrire, consultatif d’abord, bloquant ensuite (tranché).epoll: seul manque de couche 0 encore réel.- Questions sans réponse : statut d’ADR-077 (
faitoupermanent), forme de surface d’air-keystore, contrôleurs spécialisés publics ou internes par défaut.
🟡 CHANTIER EN COURS (2026-08-02) — ADR-147 : LE WORKER DE SESSION TOUJOURS CONFINÉ (option B). La campagne d’intrusion ADR-105 a relevé que le worker post-auth — celui qui parle le transport hostile — n’a aucun filtre seccomp. On ne peut pas le lui poser tant qu’il lance lui-même le shell : un filtre est hérité à travers l’
execve, et le serveur ne peut pas savoir d’avance si une connexion demandera un shell (ssh -L … hôteouvre un forward puis un shell). Inc.1 — FAIT : le lancement de session est médié. Nouveau verbe RPCSpawnSession; le monitor alloue le terminal (sous l’identité temporaire du compte, leptsreste user-owned), largue,execve, et remet au worker la voie d’I/O et lepidfdparSCM_RIGHTS; il en est le parent, donc c’est lui qui récolte et pousse l’exit-status. La commande forcée (command="…") est désormais appliquée par le monitor, jamais écrite par le worker. Iso-comportement : la cage n’est pas encore posée. Inc.2 — FAIT : la cage est posée dans TOUS les cas, et avant le premier octet lu du pair — pas seulement avant le démux : la couche connexion parse elle aussi des données que le client contrôle, et rien n’obligeait à la lui laisser parser déconfinée. Le trou d’ADR-105 est fermé : sur une connexion forward-only, le worker passe deSeccomp: 0àSeccomp: 2, et un test root le lit dans/procplutôt que de le supposer. Le profilsftp-workern’est pas élargi — les quatre gestes qui n’y étaient pas ont été retirés (shutdown(2),MSG_PEEK) ou médiés par le monitor (ioctl(TIOCSWINSZ),pidfd_send_signal→ nouveau verbeSessionControl). ⚠️ Ces deux derniers sont une découverte de l’inc.2 : ADR-147 §2 affirmait que le profil couvrait déjà tout ce que le démux et ses pilotes appellent, ce qui était vrai des pompes de redirection mais faux du pilote de session (jamais exercé cagé jusqu’ici, SFTP et shell étant exclusifs). L’ADR mérite un amendement sur ce point. Reste : inc.3 (durcissement : profilsession-demuxsans les syscalls fichier, non bloquant).🟢 CHANTIER CLOS (2026-08-01) — ADR-146 : LE FORWARDING SERVEUR SOUS PRIVSEP. Jalons V2.6-1a → V2.6-3 streamlocal-L mergés (#579→#596) : canal RPC
ForwardConnect,ssh -Lservi, plomberie puisssh -Rséquentiel puis concurrent,connect(2)asynchrone côté monitor, démux unifié-L+-Ravec écoutes multiples, la session dans le démux (un shell et un-L/-Rsur la même connexion), son durcissement non bloquant, la coexistence du SFTP cage comprise, puisssh -Lvers un socket Unix joint sous l’identité temporaire du compte. Dernière brique, mergée : V2.6-3 streamlocal-R —ssh -Rvers un socket Unix (streamlocal-forward). Le monitor crée le socket demandé sous l’identité TEMPORAIRE de l’utilisateur (AirTemporaryIdentity) : il est user-owned en0600et ne peut naître que là où l’utilisateur pouvait déjà écrire — aucune escalade par construction, aucun socketroot:root, et le chemin créé est supprimé sur tous les chemins de fin. Détail des jalons : section « EN COURS — ADR-146 » plus bas.🟢 Où on en est. La vague 1 (ADR-107→113) est close. La vague 2 est entamée — état lu dans
statuts.toml, seule source de vérité :air-json(114) et CLIair-keystore(115) faits ;air-agent(116), redirections/mux (117) et config capacités (118 — tous les incréments serveur faits, inc.1→8 ; ne restent que les options client du §4, rattachées à ADR-116) en cours ; extensions SFTP (119), hostbased (120) et provisioning (121) à faire. S’y ajoutent le privsep du forwarding (ADR-146, fait), le confinement du worker de session (ADR-147, en cours — inc.1 et inc.2 faits) et l’isolation par namespaces (ADR-148, à faire).📍 ADR-118 (config binaire, capacités étendues) — inc.8 fait (2026-08-03) : dernier incrément SERVEUR. Après le bornage du forwarding (inc.1, #575) et le contrôle d’accès serveur (inc.2, #614), le cœur de l’ADR : les blocs
Match— une liste ordonnée de règles conditions → surcharges dans le modèle binaire, évaluée par connexion en couche 2. Conditions :users,groups,source(CIDR v4/v6, l’adresse vue par le listener, jamais annoncée par le client) ; toutes ANDées ; un bloc sans condition matche tout. Surcharges : les six champs d’inc.1/inc.2, chacune optionnelle. Sémantique fixée et documentée : on part de la base, on parcourt les blocs dans l’ordre, chaque bloc qui matche applique ses surcharges — la dernière correspondance qui fixe un champ gagne, et l’arbitrage est par champ, pas par bloc. Fail-closed : groupe irrésolu ⇒ le bloc ne matche pas ; bloc ou CIDR malformé ⇒ le démon ne démarre pas. La politique effective est calculée une fois, à l’ALLOW(compte, groupes et adresse y sont établis), puis transportée jusqu’au médiateur de forwarding : le forwarding est désormais par connexion, plus par-démon. Anti-énumération préservée — la résolution des groupes boucle sur la configuration entière (base + blocs) et le leurredummy_readla paie aussi. Trois points laissés au superviseur, documentés : (a) pas de conditionHost(elle supposerait un reverse-DNS qu’ADR-129 §D4 refuse) ; (b) la règle dernière-correspondance ; (c) le transport de la politique effective. Refus de démarrage si des blocsMatchcoexistent avecprivilege-separationdésactivé : le chemin monolithique n’a ni monitor pour évaluer, ni médiation pour appliquer — une demi-politique serait pire que le refus. Écriture des blocs : par JSON (config import), la grammaire plateconfig setne décrivant pas une structure imbriquée — c’est ce qu’inc.4 a corrigé.inc.4 (2026-08-03) — les blocs
Matchdeviennent éditables en ligne de commande, et le piège du replace-vide est fermé. Aucune sémantique de politique ne change (évaluateur intact, schéma intact, pas de bump de version) : c’est de l’outillage et de la visibilité. Six verbes,air-sshd config match list|add|set|unset|open|remove <path> …—listnumérote les blocs (l’index est la priorité),addajoute en fin (position la plus forte) et refuse un bloc sans condition ni surcharge,unsetrend un champ à l’héritage,removedit qu’il réindexe. Validations partagées avec l’import JSON (un seul parseur de CIDR pour les deux chemins d’écriture). Le garde-fou : sur un blocMatch,allow_users = []retire la restriction au lieu de la poser (la surcharge remplace ; une liste d’autorisation vide ne filtre personne — parité OpenSSH) ⇒setrefuse la liste vide en orientant vers les deux suites légitimes, un verbeopenla pose explicitement (seul chemin CLI versSome([])), et toute lecture avertit —config get,config match listet le chargement de la config par le démon, une fois, surstderr. Le démon démarre quand même : le replace-vide est une politique valide, seulement piégeuse. Inspection pure (aucune I/O de plus, rien par connexion : pas d’oracle ADR-105). Le JSON reste la voie brute (ADR-032). Écriture fail-closed : réencodage → relecture → comparaison avant d’écrire sur le disque. Guide utilisateur :guides/air-sshd-blocs-match-fr.md.inc.5 (2026-08-03) —
ForceCommandserveur, base et surchargeMatch. Aucune mécanique d’exécution neuve : le monitor appliquait déjà une commande forcée par clé (ADR-128 §2, substituée au lancement — ADR-147 inc.1) ; l’incrément ajoute une source de plus et la fusionne dans la contrainte, sur les deux voies privsep (publickey et certificat, raccourci « politique non restrictive » compris). Précédence : la config prime sur la clé (parité OpenSSH) — l’administrateur de la machine décide de ce qui y tourne ; elle ne rend jamais un shell libre (les deux valeurs en lice sont des commandes), la demande du client part enSSH_ORIGINAL_COMMAND, et une commande forcée tourne sans PTY même souspty-req. La surchargeMatcha trois états — hérite / pose / efface —, encodésOption<Option<…>>et union à trois branches au schéma : « la commande vide » est inexprimable (et unreplacevide est refusé au décodage), donc « efface » ne se confond jamais avec une faute de frappe. L’effacement est un relâchement : traité comme le replace-vide d’inc.4 —setne peut pas le produire,config match open … force-commandle nomme,{"clear": true}l’écrit en JSON, et toute lecture l’affiche dès lors que la base impose une commande. Deux décisions de sécurité écrites : (a) laForceCommanddu serveur ne satisfait pasforced-commands-only— la faire compter rouvrirait le login root d’un serveur qui l’avait fermé, par un geste anodin ; (b)ForceCommand+privsep off⇒ refus de démarrage, comme les blocsMatch: le chemin monolithique n’applique aucune commande forcée, pas même celle d’une clé. Preuves : table de vérité complète, aller-retour des trois états, précédence prouvée en process (cert) et en root (clé nue), et e2e root avec un vraissh(supplante l’execclient sous-tt, sans TTY,SSH_ORIGINAL_COMMANDcorrect, aucune fuite vers un compte non visé). Guide : §4 du même document.inc.6 (2026-08-03) — bannière pré-authentification (
SSH_MSG_USERAUTH_BANNER, RFC 4252 §5.4). Le texte vit dans l’artefact scellé (banner @16 :Data, doctrine ADR-073/040) et non un chemin de fichier comme chez OpenSSH : le texte devient une donnée de configuration versionnée et checksummée, et l’étage qui l’émet — l’enfant pré-auth, sous cage Landlock deny-all (ADR-124 §D7) — ne peut ouvrir aucun fichier ; il la reçoit par héritage mémoire aufork, jamais par lesConnectionParams(post-auth). Le frame existait depuis U.1 (userauth_banner, message 53) : l’incrément le câble et complète ses preuves. Décision structurante : AUCUNE surchargeMatch— la bannière est pré-auth, la conditionner àusers/groupsobligerait à résoudre un compte avant de l’avoir authentifié, et sa présence deviendrait un oracle d’énumération (ADR-105), soit la fuite queserve_userauthferme partout ailleurs. Le refus est porté par le type (le champ vit surSshdSettings, hors du bloc que lesMatchRulesurchargent) et gardé par un test de structure. Émise une seule fois, juste après leSERVICE_ACCEPT— donc avant que le client n’ait nommé le moindre compte : ni le contenu ni la décision d’émettre ne dépendent d’une entrée client. Étiquette de langue vide (comme OpenSSH). Contrairement à inc.3 et inc.5, aucune réserve de mode : la bannière est émise parserve_userauth, que privsep et monolithique partagent, et elle précède l’ouverture de tout canal (unssh -N/-L/-Rla reçoit). Surfaces :config set <path> banner '<texte>'(un seul argument) / sans valeur (retire, muet — un texte n’exerce aucun contrôle) ; JSON"banner"chaîne ounull, toujours émis (ADR-032), bannière vide refusée des deux côtés (l’artefact ne la distinguerait pas de l’absence).config getéchappe les caractères de contrôle : une bannière porteuse de séquences ANSI pourrait réécrire les lignes queconfig getvient d’imprimer, donc mentir à l’administrateur dans la commande même qui sert à vérifier. Preuves : frame (octets exacts, cas limites), aller-retour binaire + rétro-compat (artefact d’avant inc.6 ⇒None), JSON/CLI et refus, échappement d’une bannière hostile, e2e transport (bannière avant leSUCCESS, langue vide ; aucun message 53 sans configuration) et e2e root (un vraissh(1)l’affiche à travers la chaîne privsep, avec témoin). Guide : §5 du même document.inc.7 (2026-08-03) —
MaxSessionsserveur, base et surchargeMatch, avec un périmètre0|1assumé.air-sshdne sert qu’un canal de session par connexion : c’est codé en dur en couche 2, et à deux endroits — la couche connexion (« only one session channel ») et le démultiplexeur du worker, qui oppose le même refus à une session demandée après qu’une redirection a pris le transport. Le multiplexage multi-sessions est un chantier architectural, hors incrément :> 1est donc refusé au chargement (le démon ne démarre pas), auconfig setet à l’import JSON, plutôt que servi comme1en silence — Air n’importe aucune config texte d’OpenSSH qui l’y obligerait. Reste la valeur qui fait l’incrément :0= compte tunnel-only. Il ne ferme pas le compte, il lui retire le shell (aucun canal de session : shell/exec/subsystem) en gardant tout le forwarding (-L/-R/-N) — le compte de rebond, posable parMatch user=backup max-sessions=0. LE piège de l’incrément, et sa parade : ici le zéro capnp n’est PAS le comportement d’avant (0= « aucune session »), contrairement à tous les autres champs additifs de ce schéma ; unUInt32nu aurait coupé le shell de tous les comptes à la mise à jour, en silence. D’où une unionabsent/limit(@17/@18) :absentest le zéro du discriminant, donc ce que rend un artefact muet, et il vaut1— préférée au décalagen+1parce qu’elle se lit sans se souvenir d’un offset et qu’elle est déjà l’idiome du fichier ; même précaution à l’import JSON (clé absente ounull⇒1). La valeur effective (base +Match, calculée à l’ALLOWcommeForceCommand) voyage jusqu’aux deux points d’enforcement : n’en fermer qu’un aurait laissé un chemin vers le shell (ouvrir un-L, puis demander une session). Refus mot pour mot identique à celui d’une session surnuméraire — pas de nouvel oracle de politique (ADR-105). Contrairement à inc.3/inc.5, aucune réserve de mode pour la valeur de base : refuser un canal ne demande aucun privilège, donc le monolithique l’applique aussi (la surchargeMatch, elle, hérite de la contrainte d’inc.3). Preuves : table de vérité, aller-retour des deux valeurs et des trois états de surcharge, refus de> 1(artefact base et bloc, CLI, JSON), rétro-compat (artefact d’avant inc.7 ⇒1), les deux enforcements isolés, et e2e root (shell refusé / tunnel servi pour le même compte, plus le témoin d’un bloc visant quelqu’un d’autre). Guide : §6 du même document.inc.8 (2026-08-03) — keepalive serveur (
ClientAliveInterval/ClientAliveCountMax), dernier incrément serveur d’ADR-118. Il règle un vrai trou d’exploitation : un pair mort (machine éteinte, capot fermé, NAT qui oublie) ne ferme pas sa connexion — il se tait, et le worker qui le servait reste bloqué jusqu’au redémarrage avec son descripteur, ses écoutes-Ret son crédit d’admission. Mécanique OpenSSH : toutes lesintervalsecondes sans un octet reçu du client, une requête globalekeepalive@openssh.comavecwant_reply; tout paquet reçu (sa réponse comme n’importe quel trafic) remet le compteur à zéro ; au bout decount_maxsondes consécutives muettes, la connexion est fermée. ⚠️ Correction d’un présupposé de l’ADR : §5 annonce « réutilise les timers d’air-async» — faux là où le keepalive doit vivre. Post-auth, la connexion est servie dans le worker confiné, qui n’a aucun réacteur (pas unio_uring_*dans son profil seccomp ; l’exécuteur poll une future une fois,Wakerinerte, ADR-129 §D1) : unRuntime::timeoutn’y serait jamais réveillé. Le keepalive est donc une échéance de plus dans leppollque la boucle fait déjà tourner, à côté de celles des ouvertures en attente — jamais une reprise de contrôle. Réconcilier le texte de l’ADR demanderait un RFC (ADR-015). Deux points d’application, parce qu’une connexion attend à deux endroits : la couche connexion (celle qui n’a encore ouvert aucun canal — unssh -N, un-Linutilisé) et le démultiplexeur, où la connexion passe le reste de sa vie. Le seam d’I/O gagne un verbe,SshByteStream::wait_readable(timeout)— attendre sans lire —, implémenté parppollsur le flux bloquant du privsep ; sonfalsene dit pas « échéance atteinte » mais « rien appris » (délai,EINTR, réveil vide), c’est l’horloge qui tranche, donc aucun signal ne fait partir une sonde en avance. La décision est une fonction pure sortie des deux boucles (Wait/Probe/Disconnect), éprouvable à la nanoseconde près. Base-only, sans surchargeMatch: le keepalive ne décide d’aucun accès, et le refus est porté par le type (il vit hors de l’AccessControlPolicy). Le piège capnp, deuxième round : l’intervalle n’en a pas (0= désactivé = comportement d’avant), mais le compteur si — son zéro dirait « aucune sonde tolérée » ; même parade qu’inc.7, une unionabsent/count(@20/@21) dont la branche zéro vaut3, et la combinaison incohérente (count_max 0+ intervalle actif) est refusée sur les trois surfaces (CLI, JSON, chargement). Monolithique : refus de démarrage (io_uring n’offre aucune attente bornée — ses sondes ne partiraient jamais), comme pour inc.3 et inc.5. Non-régression par construction :interval = 0n’injecte aucune échéance et laisserecv_packetsur son chemin d’avant. Preuves : décision pure, sonde sur le fil + fermeture dans les deux boucles avec témoin d’un pair qui répond, arithmétique duppoll(plus proche des deux échéances), config (aller-retour binaire + JSON, rétro-compat0/3, refus de cohérence et de bornes, CLI), refus du monolithique, et e2e root : un vraissh -N -Lsilencieux cinq secondes reste servi (il répond aux sondes), le même client figé parSIGSTOPvoit sa chaîne privsep terminée, et le témoin keepalive éteint ne la termine pas. Guide : §7 du même document.Prochaine action : les options client (
IdentitiesOnlypar défaut,ServerAliveIntervalcôtéair-ssh— rattachées à ADR-116). Hors périmètre, documentés comme suivi : le multiplexage multi-sessions (MaxSessions > 1, chantier architectural), les motifs glob (admin*,user@host), les listesDenyUsers/DenyGroups, et un blocMatch all(sans condition) posable en CLI — il ne s’écrit aujourd’hui que parconfig import, puisqueaddne pose aucune surcharge et refuse donc le bloc vide.⚙️ Outillage documentaire (2026-08-01, #587/#588). Le journal de bord (
JOURNAL.md) avait cessé d’être tenu après la PR #315 — 263 PR accumulées. Rattrapé, consigne inscrite dansCLAUDE.mdetAGENTS.md(tout agent, LLM ou humain, le tient), et outillée :scripts/verifier-journal.pyéchoue au-delà de 10 PR sans nouvelle entrée. Au passage, la CI ne relance plus les jobs de code sur un diff sans code Rust/C/C++/Swift, etscripts/test-ci-perimetre.shdevient une étape obligatoire du jobchanges— cf.CI.md.
Vague 1 — ✅ CLOSE (rappel d’avancement) :
- [1]
air-keystore(ADR-108) — ✅ FAIT (mergé #463 ; CLI ADR-115 close #536). Nouveau manager de domaine couche 1 (cycle de vie des clés/secrets). Bricques faites (tout vert sur notrestdlinux-air, fmt/clippy/layers/C-surface OK) :
- (a.1)
wire(codec fil SSH possédé, borné anti-hostile) +formats::public_key(clé publique OpenSSH : parse/encode, round-trip, interopssh-keygen). — (a.2)keys(paire Ed25519 gérée : génération CSPRNG,from_seed,sign, zeroize ; délègue air-crypto). — (a.3)formats::private_key(importopenssh-key-v1: PEM+base64+wire bornés, validations de cohérence graine/pubkey/ blob/padding, toutes branches de rejet via conteneur synthétique). — (b partiel) harnais cargo-fuzz des deux parseurs (300 k runs verts).- (store) magasins scellés TYPÉS PAR RÔLE FAITS (décision BDFL 2026-07-27) : nouveau crate
air-keystore-schema(un capnp par rôle) + modulestore(enveloppeAIRCFGv1+FNV-1a-64 réimplémentée no_std, octet-compatible air-account ; I/O atomiqueair-filesystem;StoreVisibility= permissions par rôle) + les 3 rôles :hosts(known_hosts TOFU 0644,verify_host),revocation(RevokedKeys 0644,is_revokedsans court-circuit),hostkeys(clés d’hôte 0600, graine zeroïsée, péremptionis_valid_at, blob revalidé). Additif air-cryptoexpose_seed(accès nommé à la graine). 70 tests verts sur notre std (capnp + écriture atomique /tmp via le PAL — pipeline scellé COMPLET sans glibc). Couverture crate ~97,7 % lignes.- (export + fuzz magasin) FAITS :
formats::encode_openssh_private_key(symétrique de l’import ; interop réelle prouvée —ssh-keygen -ylit notre export ; graine zeroïsée) ; cible cargo-fuzzfuzz_air_keystore_storedes 3 décodeurs (400 k runs).- Restant air-keystore :
ca(avec ADR-109) · déchiffrement passphrase (Argon2) · dudect autorisation · migrationair-sshd. La bibliothèque autonome est complète (~14 commits) ; restent l’intégration etca/certs (ADR-109).- MIGRATION air-sshd — clé d’hôte : FAITE (décision BDFL 2026-07-27, déplacement vers le magasin
hostkeys.aircfg, [[migration-air-sshd-hostkeys-keystore]]). Étapes 1-3 + JSON faites : air-sshd dépend d’air-keystore ; schémasshd_confighostKeyStorePath @5additif (hostKeys @2déprécié/réservé ADR-012) ;config.rslit/écrit le champ, invariant « ≥1 clé » relâché (graines OU magasin), compat ascendante structurelle ; daemonload_host_keycharge viaair_keystore::hostkeys::loadsinon repli graines ; JSONhost_key_store. 162 tests air-sshd verts sur notre std (air-keystore tiré transitivement, sans glibc), interop OpenSSH inchangée. DÉCISION BDFL 2026-07-27 :authorized_keysRESTE dans air-sshd (magasin binaire mûr ADR-096, fuzzé+dudect’d — on ne refactore pas un sous-système de sécurité éprouvé) ⇒ MIGRATION air-sshd CLOSE. Restant (hors migration) : provisioning écrivanthostkeys.aircfg(ADR-121).- [2] certs+CA (ADR-109) — ✅ FAIT (chantier CLOS #529 ; émission + clé CA #528, ADR-140) (air-keystore ADR-108 MERGÉ #463 + migration clé d’hôte). Incréments : (a) codec + vérif de cert
ssh-ed25519-cert-v01@openssh.comdansair-ssh-proto(MERGÉ #464, interopssh-keygen↔ byte-exact) · (b) magasin des CA de confiancecadans air-keystore (MERGÉ #465,is_trusted_capar périmètre) · (c) émissionAirSshCertificate::issue(interop prouvée :ssh-keygen -Llit un cert émis par Air) · (d.1) combinateur de vérificationverify_certificate(fail-closed : type/signature/CA-confiance/validité/principal/options-critiques/révocation ; politique = fermetures, keystore branché par le toit). (c)+(d.1) MERGÉ #466. · (d.2 SERVEUR) : userauthair-sshdaccepte une clé portant un cert (possession = clé certifiée,air-ssh-proto) +CertAwareAuthorizer(vérif vs magasin CA de confiance + révocation, fail-closed) routé dansserve_userauth+ démon charge les magasins (configtrustedCaPath @6/revokedKeysPath @7) et active réellement l’auth par cert. Rétrocompatible. Vert gnu + notre std (174 lib + 19 newkeys + interop). Branchefeat/adr-109-cert-auth. · (d.2.c CLIENT) — FAIT (branchefeat/adr-109-inc1-client-host-cert) : la confiance d’hôte par certificat est câblée dansair-ssh. Nouveau moduleair-sshd::host_trust(étage de décision pur,evaluate_host_key: cert d’hôte ⇒ vérif fail-closed contre les CA d’hôtes du magasintrusted_ca.aircfgpérimètreHost— type/signature/CA-confiance/validité/principal = nom d’hôte contacté/options-critiques/révocation ; clé nue ⇒PlainKey, cheminknown_hosts/TOFU inchangé) ;establish_client_with_host_trusttranche avantNEWKEYS(aucun secret présenté à un hôte refusé) et rendClientHandshakeError:: HostRejected(raison); config clientssh.aircfgadditive (trustedHostCaPath @3/revokedHostKeysPath @4, CLIconfig set trusted-host-ca|revoked-host-keys, JSON export/import), magasin illisible ⇒ refus de démarrer. Deux durcissements assumés vs OpenSSH : cert d’hôte sans principal ⇒ refus (la liste vide vaudrait pour tout hôte) ; nom d’hôte abaissé en casse ASCII avant comparaison. 24 tests unitaireshost_trust+ 8 tests bout-en-bout (tests/host_cert_client.rs, vrai handshake avec serveur présentant un cert d’hôte, magasin scellé relu depuis le disque). air-sshd 0.3.1 → 0.4.0 (#522). (Le check « principal = nom d’hôte » de cet incrément a été remplacé par l’identité machine d’ADR-137, ci-dessous.) · (d.2.d IDENTITÉ MACHINE, ADR-137) — FAIT (branchefeat/adr-137-identite-machine, décision BDFL 2026-07-29) : une machine s’identifie par sa clé, pas par son nom. Nouvel ADR-137 (RFC de direction ; raffinement de sémantique d’ADR-109, aucun descellement). Nouveau moduleair-sshd::host_identity(couche 2, pur, zéro I/O) :key_fingerprint(empreinte OpenSSH d’un blob de clé publique) +machine_identity(fpr(K_pub)d’une clé Ed25519, blob construit sur la pile, sans allocation ni chemin d’échec) ; KAT byte-exact contressh-keygen -l; l’empreinte deauthorized_keys_cli::fingerprintdélègue ici (une seule implémentation dans le crate). Bascule du client : le check « principal = nom d’hôte contacté » cède la place à l’invariant de cohérence de soi (ADR-137 §3) — le client recalculefpr(K_pub)depuis la clé prouvée au KEX (la clé certifiée) et exige qu’elle figure dansvalid_principals; le nom d’hôte n’entre plus dans la décision (ClientHostTrust::newperd son paramètrehostname,PrincipalMismatchdevientIdentityMismatch), et la comparaison est byte-exacte (l’empreinte est de la base64 : la normalisation de casse est retirée, elle ferait collisionner des identités). Tout le reste inchangé : CA d’hôtes de confiance périmètreHost, validité, révocation, options critiques, fail-closed avantNEWKEYS, configssh.aircfg, clé nue ⇒known_hosts/TOFU. 26 tests unitaires + 9 bout-en-bout ; 5 d’entre eux vérifiés ROUGES sans l’invariant (cert menteur, cert à principal = hostname, casse) ; fuzzfuzz_air_sshd_host_trustétendu. air-sshd 0.4.0 → 0.5.0 (rupture d’API assumée). HORS périmètre de cet incrément (par construction) : l’émissionvalid_principals = [fpr(K_pub)](= ADR-109 Inc.4 + CLI) et lepostinstqui initialise le keystore + génère la host key (ADR-137 §4 = incrément packaging). Restant : test d’interop réel (sshOpenSSH avec cert → air-sshd, etair-ssh→sshdà cert d’hôte, ADR-137 §5HostkeyAlias) ; présentation d’un cert d’hôte par le serveurair-sshd(aujourd’hui il ne présente qu’une clé nue) ; pinning TOFU des clés nues (knownHosts @5, différé ADR-099 §1) ; (e) CLI = vague 2. · (Inc.3 RÉVOCATION PAR SERIAL + KRL, ADR-138) — FAIT (branchefeat/adr-109-inc3-revocation-serial-krl) : on révoque un numéro de certificat sans détenir la clé, et une flotte mixte n’entretient qu’une liste. Nouvel ADR-138 (descellement ADDITIFcouche-1-v3.6→couche-1-v3.7— tag à poser par le superviseur). air-keystore :AirRevokedSerial(plagemin..maxbornes incluses, CA émettrice obligatoire — un serial n’a de sens que chez son émetteur —None= joker « toute CA », serial0jamais révocable),AirRevokedCertKeyId,AirRevokedFingerprint, et l’agrégatAirRevocationStore(quatre listes ; c’est LUI qu’on charge/écrit/interroge ;empty()const fn). Schéma capnp additif (serials @2/certKeyIds @3/fingerprints @4) ⇒ magasin antérieur = listes vides, faces historiques inchangées. Nouveau moduleair_keystore::krl(couche 1, sans-IO ADR-091) : codec KRL OpenSSH en import et export — en-tête,certificates× {serial-list, serial-range, bitmap, cert-key-id},explicit-key,fingerprint-sha256. Interop prouvée dans les deux sens : KAT = KRL réellement produits parssh-keygen(OpenSSH_10.2p1 sur carbon) décodés et réécrits octet pour octet, +tests/krl_openssh_interop.rscontre le binaire réel (un export d’Air relu parssh-keygen -Q -l). Fail-closed intégral (extension critique, serial 0, plage inversée,mpintnon canonique, > 65 536 plages, CA non Ed25519 ⇒ refus) ; sectionsfingerprint-sha1etsignaturerefusées (différées, ADR-138 §5). Fuzzfuzz_air_keystore_krl— 4 M runs avec corpusssh-keygen, zéro crash ; proptestdecode ∘ encode = identité. Câblage : une seule règle de révocation de cert (air_sshd::cert_revocation) partagée serveur et client (deux copies supprimées), consultant clé certifiée +serial+key idrelativement à la CA signataire, sans court-circuit. Aucun réglage nouveau (le magasin déjà désigné porte les serials). Couverture 100 % lignes surkrl.rs/revocation.rs/cert_revocation.rs. Fix post-revue (F1) : danskrl::to_revocation_store, la borneMAX_REVOKED_SERIAL_RANGESétait vérifiée après la boucle d’accumulation — un KRL hostile enchaînant des sous-sections (chacune sous la borne unitaire) matérialisait des millions de plages avant rejet (pic mémoire prohibitif sur Pi 4). Corrigé : borne incrémentale (rejet dès franchissement) + test multi-sous-sections. Merge #524 + tagcouche-1-v3.7par le superviseur après CI verte.- (Inc.2 CERT UTILISATEUR SOUS PRIVSEP, ADR-139) — ✅ LIVRÉ (#526, main
91ec7c4). 5 sous-étapes : message RPC dédiéAUTHORIZE_CERT(tag 4, blob brut) ; le monitor re-parse
verify_certificate+ traduction extensions→permissions / force-command→forced_command / source-address→pair ; l’enfant délègue ; gardetrusted_ca+privsep levé ; test privilégié root 5/5 (ssh -i cert traverse la chaîne privsep) ; fuzz « le monitor décide un cert » (971k+3k runs, 0 crash). Couche 2 pure, aucun re-sceau. Revue adverse : 0 finding bloquant (2 durcissements transverses fast-follow notés : options critiques dupliquées/hors-ordre dans cert.rsparse[O2] ; principal user vide [O3] déjà traité côté émission par ADR-140 §D5). Suite = Inc.4/Inc.5 (ADR-140).🔵 ADR-143 (couverture/tests CI PAR COUCHE) — EN REVUE, 2026-07-30
Mise en œuvre de l’ADR-143 (décision BDFL du jour) : le job
test-coveragene teste et ne mesure plus que les couches touchées. Branchefeat/adr143-coverage-par-couche. Aucune couche 0/1 touchée (outillagextask+ CI seulement), aucune version bumpée.
xtask couvrable-vide --layer N—llvm-covreçoit-p <crate>pour les seules crates dont[package.metadata.air] layer = N; aucune règle ne change, seul le périmètre : réconciliation ligne à ligne des crates réconciliés + plancher agrégat + plancher branches s’appliquent à l’identique sur ce qui a été mesuré. Le mode sans--layerreste le mode complet, strictement inchangé.xtask crates-of-layer N [--and-above] [--names]— helper à sortie brute (-p a -p b …) qui donne au shell deci.ymlle périmètre decargo test. Même source de vérité quecheck-layers; scope vide = erreur, jamais un silence.ci.yml— le jobchangesmappe chaque fichier modifié vers la couche de son crate et sortcouche0/couche1/couche2+full_forced(fichier CI/xtask/rt/manifeste racine/toolchain/politique, ou fichier non rattachable ⇒ mode complet, on ne devine pas). Tests : complet → workspace ; couche 1 → crates 1+2 ; couche 2 → crates 2 seuls (les gros interop réels SFTP/ssh/ABI vivent en couche 2). Couverture : une étape--layer Npar couche touchée.- Mode complet désormais réservé aux occasions rares : tag (release/scellement),
workflow_dispatch, nightly (cronquotidien, resserré depuis l’hebdo), couche 0 touchée,full_forced. RETIRÉ : le déclencheurpush main(décision BDFL) — la PR a déjà vérifié sa couche en pré-merge ; le filet devient nightly + tag.- Limite assumée et documentée : une couche mesurée seule ne bénéficie plus des tests des couches supérieures ⇒ mesure potentiellement plus sévère (faux échec possible, jamais faux succès). D’où l’absence de
--layer 0en CI : couche 0 touchée ⇒ complet.- La PR touchant
ci.yml/xtaskdéclenche elle-même le mode complet : elle se valide en couverture complète. Docs mises à jour :CI.md,outillage-xtask.md.✅ ADR-109 (certificats SSH + CA) CLOS et DÉPLOYÉ — 2026-07-30
Tous les incréments livrés : Inc.1 (cert hôte client #522), Inc.3 (révocation serial+KRL #524, tag
couche-1-v3.7), Inc.2 (cert user privsep #526), Inc.4/Inc.5/postinst (#528, tagcouche-1-v3.8). Docs ADR-139 (#525) + ADR-140 (#527). Déploiement.debair-sshd 0.6.0 RÉUSSI sur les 3 machines (0.3.1→0.6.0) : postinst idempotent (host key préservée,Mstable), service actif :2222, auth réelle prouvée sur chacune.M: speedySHA256:WXPGem4u…JN50, carbon(air-test-host)SHA256:W7EFQDlL…HuI, raspi-srv-2SHA256:EKgyeLCr…N21E. Réserve ADR-137 §5 CONFIRMÉE puis TRANCHÉE (BDFL 2026-07-30 : documenter la limite, ne PAS affaiblirMpar défaut ; chemin opt-in en ADR-115 §6, livré en ADR-115 inc.4) : interop cert d’HÔTE par client OpenSSH inopérante (OpenSSH abaisse la casse duHostkeyAlias,Mcase-sensitive ne matche jamais) — cert utilisateur OpenSSH prouvé, client Air non concerné. Fast-follow notés : [[durcissement-parseur-options-critiques-cert]] (O2), zeroize transverse keystore (F2/F3). Suite vague 2 = CLI air-keystore (ADR-115).✅ ADR-141 D1 + D5 (durcissement cert) — MERGÉ (#539), 2026-07-30
Deux items du registre de durcissement ADR-141, tous deux couche 2 (aucune couche 1 touchée, aucune version bumpée) :
- D1 —
air_ssh_proto::cert::parserefuse les options critiques ET les extensions dupliquées ou hors ordre. Le nom lu doit être strictement croissant (octets) : égal = doublon, inférieur = hors-ordre, les deux font échouer l’analyse (fail-closed, aucune valeur jamais écrasée). Règle dePROTOCOL.certkeysappliquée aux deux sections. Parité vérifiée chez les émetteurs réels :ssh-keygentrie (cert_ext_cmp),x/crypto/sshtrie ET applique déjà ce même rejet à l’analyse — aucun certificat légitime n’est cassé (les 2 KATsssh-keygendu crate restent verts, un test verrouille que leurs extensions sont strictement croissantes). Effet transverse : parseur partagé ⇒ mono + privsep + client + CLI d’un coup.issuerefuse en outre un nom en double.- D5 —
air-keystore cert verifyn’énonce plus de contrôle tautologique. (1) CA fournie en.pub: le rapport dit que le périmètre n’a pas été vérifié indépendamment (texte etwarnings[]JSON, à l’acceptation comme au refus) —load_trusted_casrend désormais la provenance (AirTrustedCaOrigin) ; verdict inchangé. (2) certificat utilisateur :--principalexigé (choix « exiger » plutôt qu’« avertir » — rien n’est perdu,cert inspectreste le verbe d’inspection) ; le jeton JSONprincipal_origin: first-principaldisparaît, cette provenance n’étant plus atteignable. Certificat d’hôte inchangé (identité machine recalculée, ADR-137 §3).Trouvé en passant (défaut préexistant du round-trip
reserved) : corrigé — voir l’entrée ADR-141 D9 ci-dessous.🔵 ADR-141 D9 (round-trip
reserveddu certificat) — EN REVUE (#540), 2026-07-30Le fuzz de #539 faisait tomber l’assertion de round-trip de
fuzz_air_ssh_certsur un certificat àreservednon vide :parsejetait le champ (_reserved),serialize_to_be_signeden réécrivait un vide, doncencode()n’était pas byte-exact (139 → 135 octets). Défaut PRÉEXISTANT vérifié surmain, sans rapport avec D1 (l’entrée n’a ni option ni extension). Impact sécurité nul : la vérification porte sur les octetsto_be_signedcapturés depuis le blob d’origine (reservedréel inclus), jamais sur une resérialisation — défaut de correction, pas de sécurité.Correctif (couche 2, additif) :
AirSshCertificategagne un champ privéreserved, conservé à l’analyse et ré-émis tel quel ⇒encode(parse(blob)) == blobbyte-exact pour tout certificat valide.issueémet toujours unreservedvide (parité OpenSSH — le champ ne sert qu’à préserver ce qui est analysé) ;AirCertParamsinchangé, parseur toujours fail-closed, D1 et la vérification de signature intacts. Tests : certificat réellement signé aureservednon vide (ré-encodage byte-exact + signature vérifiée + re-parse identique), cert émis byte-égal au blob àreservedvide, KATs OpenSSH verts, fuzz 0 crash. Registre ADR-141 annoté : D1/D5 ✅ (#539), D9 ajouté et ✅ (#540). Aucune version bumpée.🔵 ADR-141 groupe C — intégrité du gate (D6 + D8) — EN REVUE (#542), 2026-07-30
Couche 2 seulement (
air-keystore-cli), aucune version bumpée, aucun re-sceau.
- D6 — la base de credentials du gate n’est plus nommable par l’appelant.
--admin-basedisparaît de la surface CLI :argvet payload--json-inputle refusent désormais comme argument inconnu, sur les lectures comme sur les mutations. En production,admin_baseest une fonction#[cfg(not(test))]qui rend le chemin système en dur (air_admin_gate::DEFAULT_BASE_DIR=/etc/air, là oùair-accountgèreshadow.aircfg). L’injection de test passe par un champ#[cfg(test)]deCliEnvironment: absent du binaire livré ⇒ la redirection n’est pas seulement interdite, elle est inexprimable. Test central : deux bases actives — la « système » injectée et un piège dont l’appelant connaît le secret — ni l’argvni le payload ne font du piège le juge (secret du piège refusé, seul celui de root dans la base système autorise). Effet de bord utile : les tests ne dépendent plus en silence du/etc/airde la machine qui les exécute.- D8 —
ca create/cert issue-*: non-gate CONFIRMÉ, comportement inchangé, motif écrit dans le code : ce sont des artefacts à chemin nommé (façonssh-keygen), pas des mutations d’un magasin de confiance système (les seuls gatés, ADR-115 §3) ; un chemin système y est déjà protégé par les permissions FS (EACCES, CLI non privilégiée).Registre ADR-141 : plus aucun correctif en attente (D1/D5 #539, D9 #540, D2/D3/D7 #541, D6+D8 #542). Seul D4 reste ouvert en tant qu’option (politique « principal explicite obligatoire », divergence assumée d’avec OpenSSH) — à ouvrir sur décision, pas une dette.
✅ ADR-115 (CLI
air-keystore) CLOS — 2026-07-30Les quatre incréments sont livrés : inc.1 squelette + orchestration partagée (#532), inc.2 mode JSON agent-safe (#533), inc.3 magasins + gate admin ADR-101 (#534), inc.4 clôture (cette PR). Aucune couche 1 touchée sur toute la série, aucune version bumpée.
- (ADR-115 inc.4 — CLÔTURE : retrait de la CLI provisoire d’
air-sshd, compatHostkeyAlias,key generate) — EN REVUE (branchefeat/adr-115-inc4-cloture). (4.1) La CLI provisoire d’air-sshdest RETIRÉE : les verbesair-sshd ca init|showetair-sshd cert issue-host|issue-user(ADR-140 §D6, dette datée) disparaissent avec leur modulecert_cli— ils sont relogés dansair-keystore ca create|show/cert issue-host|issue-userdepuis l’inc.1.air-sshdne charge donc plus jamais de clé CA privée, sous aucun verbe. GARDÉS intacts :init-host-key(appelé par lepostinst, ADR-137 §4 — le.debn’est pas cassé),config,authorized-keys,migrate-host-key-store; version d’air-sshdinchangée, tests non liés verts. L’interop réel d’ADR-140 §D7 (cert_issue_interop.rs) suit l’outil qui émet : déplacé versair-keystore-cli/tests/, mêmes assertions, mêmesshdOpenSSH réel. (4.2)cert issue-host --openssh-hostkey-alias-compat(ADR-115 §6, chemin opt-in d’ADR-137 §5) : ajoutelowercase(M)comme second principal,Mexact restant le premier — la vérification Air-native (ADR-137 invariant 3) est donc intacte. La CLI AVERTIT du coût (résistance aux collisions réduite, interop OpenSSH uniquement), en texte et danswarnings[]du mode JSON, et cet avertissement remplace celui de la limite — sous l’option, dire que l’interop échouera serait faux. Jamais le défaut. ⇒ La réserve d’ADR-137 §5 a maintenant sa preuve POSITIVE : un troisième test d’interop réel monte le même montage que celui qui échoue (mêmeHostkeyAlias, casse d’origine,StrictHostKeyChecking=yes) et un vrai client OpenSSH accepte le cert d’hôte d’Air. Le pliage de casse était bien la seule cause, et l’option la couvre exactement. (4.3)key generate (-f | --file) <fichier> [--comment] [--force]: paire Ed25519 (CSPRNG kernel viaair-random), clé privéeopenssh-key-v1en0600et clé publique en<fichier>.pub0644— façonssh-keygen -t ed25519 -f. Écriture atomique (le fichier n’apparaît jamais au chemin final avec un mode plus large), tampon privé zeroizé, matériel privé jamais imprimé, refus d’écrasement sans--force(sur le privé ou le.pub— pas de paire dépareillée).-fet--filesont la même option (--filepour un payload--json-input, qui nomme ses arguments sans tirets) ; les donner toutes les deux est un refus. PAS de magasin de clés utilisateur :key list|show|import|export|removerestent absents et le disent (ils supposeraient une couche 1 qui n’existe pas).- (ADR-115 inc.1 — SQUELETTE CLI
air-keystore+ ORCHESTRATION D’ÉMISSION PARTAGÉE) — ✅ LIVRÉ (#532). Deux crates couche 2 neufs, aucune couche 1 touchée, aucune version bumpée : (2.1)air-cert-issue— bibliothèque hébergeant la source unique de l’orchestration d’émission, jusqu’ici enfermée dansair-sshd(cert_issue.rs,host_identity.rs) : émission hostprincipal = M/ user, refus d’ADR-140 §D5, identité machine (ADR-137), lecture/écriture des fichiers de clés et certificats, convertisseurs de valeurs (durée, principaux,permit-*, périmètre), et les opérations d’administration rendues en résultats structurés (pas en texte — chaque façade met en forme).air-sshden est consommateur : ses modulescert_issue/host_identitysont des relais, sa CLI provisoire est inchangée à la lettre (ses 511 tests de lib + l’interop réel restent verts). Effet de bord propre :zeroizen’est plus une dépendance directe d’air-sshd(le code qui lit le magasin d’hôte a déménagé avec son effacement). (2.2)air-keystore-cli— exécutableair-keystore(calqué surair-account-cli, parsing manuel) :ca create|show,cert issue-host|issue-user|inspect|verify,key fingerprint [--lowercase](ADR-115 §6 — outil de diagnostic de la limiteHostkeyAlias, sans effet de bord).cert issue-hostavertit désormais de cette limite à l’émission.cert verifyéchoue (exit ≠ 0) sur un verdict de rejet et écrit que la révocation n’est pas consultée (inc.3). (2.3) paquet.debair-keystoreajouté àPACKAGES(sanspostinst: créer une CA est un geste explicite — ADR-140 §D4). (Ce qui était alors différé a été livré depuis : mode JSON--json/--json-inputen inc.2 (#533) ;revoke/known-hosts/host-trust/expiry+ gate admin ADR-101 en inc.3 (#534) ; retrait de la CLI provisoire,--openssh-hostkey-alias-compatetkey generateen inc.4.)- (Inc.4 ÉMISSION + CLÉ CA PRIVÉE, Inc.5 INTEROP, postinst — ADR-140) — ✅ LIVRÉ (#528, tag
couche-1-v3.8). Les cinq sous-étapes sont faites : 4.a additif couche 1air_keystore::ca_key(AirCaKey, schéma neufca_key.capnp, magasin0600scellé,may_issuedélègue àAirCaScope::covers) — lignes 100 % / branches 100 %, fuzzfuzz_air_keystore_ca_key16,7 M runs, 0 crash ; fix doc D9 (StoreVisibilitydisaitcasecret alors quetrusted_ca.aircfgest public0644). 4.b orchestration couche 2air_sshd::cert_issue(principal = Mrecalculé depuis la clé certifiée, principals explicites côté user, refus d’un user sans principal, refus d’un périmètre de CA insuffisant / extensionpermit-*inconnue / fenêtre vide). 4.c CLI provisoireair-sshd ca init|show/cert issue-host|issue-user(+ca show --outpour publier la CA publique) et verbeinit-host-keyidempotent — relogés par ADR-115. 5.a interop réel : certificat utilisateur PROUVÉ (unsshdOpenSSH réel enTrustedUserCAKeys+AuthorizedKeysFile noneaccepte notre cert). post.apostinst0755qui appelleinit-host-key(xtask, tablePACKAGES). La clé CA privée reste HORS du démon (émission = admin, processus court ;0600, Argon2 différé ADR-108 §2). ⚠️ CONSTAT BLOQUANT à trancher (ADR-137 §5) : la réserve était fondée. Le client OpenSSH (10.2p1) abaisse la casse duHostkeyAliasavant de le comparer aux principaux du certificat, orM = SHA256:<base64>est sensible à la casse ⇒Certificate invalid: name is not a listed principal. Prouvé par un contrôle interne (même montage, principal en minuscules, CAssh-keygenpure ⇒ réussit). L’émission d’Air est correcte (ssh-keygen -Lla lit) et le client Air n’est pas concerné (il recalcule l’empreinte, sans alias). Seul le pont d’interop@cert-authority+HostkeyAliasest inopérant. Remède candidat = émettre un second principal en minuscules ⇒ sémantique ADR-137, décision superviseur, hors de cet incrément. ⇒ TRANCHÉ depuis (BDFL 2026-07-30) : la limite est documentée (ADR-137 §5) etMn’est pas affaibli par défaut ; le remède existe en chemin opt-incert issue-host --openssh-hostkey-alias-compat(ADR-115 §6), livré en ADR-115 inc.4 et prouvé par un troisième test d’interop réel. Clôt ADR-109 → déclenche le déploiement.debversionné sur carbon/speedy/raspi.- (note historique, avant merge) Inc.2 avait été CONÇU (mini-ADR ADR-139, aucun code ; Option A tranchée BDFL 2026-07-29). Le constat : toute la vérif de cert utilisateur existe et est fuzzée dans le chemin monolithique, mais le privsep ne la câble pas (proto RPC sans message cert ;
MonitorAuthorizedKeyshérite du défautauthorize_cert = false; le monitor ne charge ni CA ni révocation ; le démon refuse de démarrer avectrusted_ca+privsep). Décision : sous privsep, le monitor décide la confiance, l’enfant ne fait que la crypto. Message RPC dédiéAuthorizeCertRequest{username, certificate: blob BRUT}(le monitor re-parse — un enfant hostile ne fabrique pas de confiance) ; monitor s’horloge (anti-rejeu) ; surAccepted, traduction extensions→permissions /force-command→forced_command /source-address→pair ; tout le reste → DENY muet. CA + révocation chargées au démarrage, héritées COW (données publiques ; asymétrie assumée avec la clé privée post-fork). Levée du garderesolve_privsep+ fuzz étendu. Parité stricte avec le monolithique. Couche 2 pure — AUCUN descellement, AUCUN re-sceau (révocation déjà livrée ADR-138/couche-1 v3.7). Sous-étapes 2.a proto → 2.b monitor → 2.c enfant → 2.d démon → 2.e bout-en-bout privsep. Prochaine action : implémenter Inc.2.- [3] KEX post-quantique ML-KEM (ADR-110) — ✅ FAIT (5 incréments mergés dans
main). Hybridemlkem768x25519-sha256(ML-KEM-768 + X25519), construction OpenSSH : (a) primitiveair-crypto::mlkem(crateml-kemre-validée, C-free sur linux-air, #469) · (b) re-validation à notre barre — KAT interop OpenSSL bidirectionnel (FIPS 203), fuzz 200 k, dudect |t|=0.63 (décaps constant-time, pas d’oracle) — #470 · (c) combineur hybride sans-IOair-crypto::pqkex(aléa injecté) — #471 · (d.1) cœur KEXair-ssh-proto::kexhybrid(K en string ; curve25519 byte-identique ; fuzz décodeurs 5,8 M runs) — #472 · (d.2) dispatch driver air-sshd + offremlkem768x25519-sha256(préféré, repli curve25519) + interop OpenSSH réel : sur carbon (OpenSSH 10.2p1) le vraisshnégocie l’hybride, franchit NEWKEYS et s’authentifie par-dessus le KEX post-quantique — preuve byte-exacte — #473. Restant (différé) :cert d’hôte côté clientFAIT avec ADR-109 (d.2.c) — l’étage de confiance porte sur le blobair-ssh(chemin KEX)K_S, donc indépendant de la méthode de KEX (le pilote client, lui, n’offre encore quecurve25519-sha256: l’hybride côté client reste à câbler).- [4]
air-log(ADR-111) — ✅ FAIT (4 incréments mergés #474/#476/#478). Sink de logs opérationnels (admin /journalctl), distinct d’AirCom, couche 1 no_std au-dessus d’air-socket, journald natif sanslibsystemdC, réutilise l’encodeur du cœur (décision BDFL). (a)AirLogManager(drop-safe, compteur de pertes) +AirLogSink+AirStderrSink(repli anti-injection) +AirLogFields::iter()au cœur · (b)AirJournaldSink(datagramme non bloquant surair-socket) · (c)AirFallbackSinkrésilient + interopjournalctlréel (émission → relecture) · (d) câblageair-sshd: émission directe des événements opérationnels (connexion, auth OK/échec avecAIR_USER/AIR_PEER/AIR_METHOD) vers journald, surface distincte d’AirCom (SessionAudità deux surfaces indépendantes,publisherAirCom rendu optionnel). Restant (différé, opt-in) : pont AirCom→journald générique pour tiers.- [5]
air-sandbox(ADR-112) — ✅ FAIT (dernier de la vague 1, clos #487). Confinement seccomp/Landlock sûr, compilateur BPF maison (sanslibseccompC), profils fail-closed, fin de la fuite couche 0 dansair-process. FAIT & mergé : compilateur seccomp-BPF arch-aware fail-closed (a.1 #477), allow-list sémantique (tables syscalls vérifiées sur les 2 arches, a.2 #480), profilspre-auth/sftp-worker(a.3 #481), spec Landlock (chemins → accès sémantiques, #483). Application au noyau FAIT (§3, branchefeat/adr-112-application-noyau) :AirSandbox::applypose la cage irréversible dans l’ordreno_new_privs→ Landlock (restrict_self) →seccomp(2); tests privilégiés (enfant forké) prouvant l’effet réel — syscall hors profil →SIGSYS, chemin hors politique →EACCES(couverture apply.rs = CHILD-EXIT documentée). Fuzz du compilateur FAIT (§ #485+ ; cible cargo-fuzzfuzz_air_sandbox_seccomp: totalité + bonne formation BPF — 0 saut hors borne,SockFprog::newaccepte tout programme valide ; ~3 M exécutions sans crash). Fail-closed + opt-out journalisé FAIT (§4 ;AirSandbox::enter:Enforcepose la cage ou refuse — fail-closed ;Disabled{reason}tourne sans cage mais journalise l’opt-out via air-log, jamais silencieux ; traitAirSandboxJournal
- adaptateur
AirLogManager). Refonteair-processFAITE (ADR-122 §5) : Inc.1 (#489) additifs couche 0close_range+capbset_read/capbset_drop; gate CI (#490) plancher agrégat brut →cargo xtask couvrable-vide --strictexception-aware (fin des faux-échecs privsep) ; Inc.2 (#491)AirPrivilegeDropprend une cage haut niveauAirSandboxProfile+AirLandlockPolicyvia featuresandbox(OFF, horsrustc-dep-of-std) —with_seccomp_filter/with_landlock- champs couche 0 retirés,
drop_privilegesdélègue àAirSandbox::apply⇒ plus aucun type couche 0 dans la surface publique. Inc.3 = re-sceau (ADR-123, descellement breakingcouche-1-v2.4→v3.0, 1re rupture c1 ; rupture indolore, 0 appelant ; autorisé BDFL 2026-07-28 ; tag posé par le superviseur après CI ; tagcouche-1-v3.0POSÉ sur 4ba4d26, signé, poussé). Volet 2 CONÇU : ADR-124 (conception avant code) fixe la topologielistener→monitor→enfant pré-auth→worker post-auth, le canal RPCsocketpairSEQPACKET (codec bespoke sans-IO fuzzé, monitor traite l’enfant comme hostile),SIGN/AUTHORIZEmédiés par le monitor (clé d’hôte +authorized_keysjamais dans l’enfant ; seteuid+O_NOFOLLOW, sans CAP_DAC_READ_SEARCH), transition proxy chiffré (aucun état cipher ne franchit le privilège). Plan V2.1→V2.6. 3 points sécurité TRANCHÉS BDFL 2026-07-28 :SIGN= validation structurelle (pas de recompute intégral deHdans le privilège) ; optionsauthorized_keys= appliquées par le monitor (enfant reçoit unALLOWdéjà contraint) ; uid privsep = compte système non-login_air-sshd(ADR-100/121). V2.1 FAIT : codec RPC monitorair-sshd::monitor::proto(sans-IO, borné,SIGN/AUTHORIZE+ réponses, SEQPACKET = 1 datagramme/message) — 20 tests (round-trip + entrées hostiles) 100 % lignes/fonctions, fuzzfuzz_air_sshd_monitor_proto200 k runs 0 crash. V2.2 FAIT (squelette de processus, sans crypto) :monitor::channel(tuyau SEQPACKET, lu/écrit parread/writepour tenir dans l’allow-listpre-auth, datagramme hors borne rejeté et non tronqué),monitor::service(boucle bloquante mono-filMonitorPolicy/serve+DenyAllPolicy= défaut sûr),monitor::topology(spawn_privsep:listener → monitor → enfant pré-auth; cascade PDEATHSIG avec fermeture de la course parent-mort-avant-armement ;reduce_capabilities_to({SETUID,SETGID,CHOWN,KILL})du monitor avant son fork ; purgeclose_rangedes fd hérités avant la cage ; largage vers_air-sshd+ cagepre_auth/Landlock deny-all ; refus d’un compte privsep résolvant vers uid 0). Prouvé sous root (tests/privsep_root.rs) : round-trip RPC à travers la frontière de privilège (2 messages A/R, enfant confiné ⇒ le canal tient dans la cage), enfant sous uid_air-sshd, monitor encore root, cage non vacante (ungetppidhors profil tue l’enfant avant qu’il ne parle), cascade de mort (tuer le monitor tue l’enfant, témoin par tube). Additifs couche 1 = ADR-125 (descellement additifcouche-1-v3.0→v3.1, tag à poser par le superviseur) :air-process(set_parent_death_signal/parent_death_signal,close_inherited_fds_except,reduce_capabilities_to),air-socket(AirUnixSeqpacket::write_packet/read_packet),air-sandbox(getresuid/getresgid/getgroupsaux profils — sans eux la vérification défensive dedrop_privileges, confinée depuis ADR-123, était tuée par seccomp ;AirLandlockPolicy::deny_all()—new()ne posait aucun ruleset). Tagcouche-1-v3.1POSÉ (superviseur, additif ADR-065). Fix de revue appliqué : le garde privsep rejette aussi un groupe supplémentaire gid 0 (finding #1) ; finding #2 (monitor ne purge pas ses propres fd — TCB de confiance) → suivi V2.3. Limitation 20/41 LEVÉE (ADR-126,couche-0-v1.14) :Capabilitynomme désormais les 41 caps du noyau ⇒reduce_capabilities_toborne le monitor à 4/41 exact (objectif ADR-122 §6 pleinement atteint) ; vérifié sous root (cap_last_cap=40). V2.3 FAIT (SIGNbranché — la clé d’hôte quitte l’enfant pré-auth) : seam sans-IOair_ssh_proto::hostkey(HostKeySigner+HostKeySignRequest#[non_exhaustive]+LocalHostKeySigner) ⇒ le cœur ne détient plus de clé, il demande un blobK_Set une signature ;server_kex*_with_signer/Transport::exchange_kex_with_signer/rekey_with_signerajoutés (les fonctions historiques deviennent l’habillage « signer local » — zéro rupture d’API, chemin non-privsep inchangé). Côté pilote :monitor::sign=MonitorHostKeySigner(enfant : n’a que le blob public, émet unSIGNsur le canal RPC ; refus ⇒ erreur, jamais de signature vide) +HostKeySignPolicy(monitor : détient la clé privée,Debugmuet, compteurs saturants). Anti-oracle (§D4, tranché BDFL) :validate_sign_requestpure/fuzzable — bannièresSSH-2.0-…ASCII imprimable ≤ 253 o, payloadsKEXINITstructurellement cohérents (parcours de longueurs sans allocation : byte 20 + cookie + 10 name-lists + bool + uint32, queue tolérée comme le cœur),host_keyreçu ≡ clé du monitor sinon refus,Hde longueur de hash (32/48/64) ; refus muet sur le fil (motif gardé côté monitor). Finding #2 TRAITÉ : le monitor purge ses propres fd hérités (close_inherited_fds_except) juste après son fork, ne gardant que son bout de canal + le pidfd de l’enfant — hygiène §D8 à double sens. Prouvé sous root : l’enfant confiné obtient une signature qui vérifie contre la clé publique sans détenir la privée, le monitor refuse clé d’hôte étrangère etHmal dimensionné (1 signée / 2 refusées), et un fd hérité horsinheritedest fermé dans le monitor (EBADF constaté de l’intérieur). Fuzzfuzz_air_sshd_monitor_sign(10,2 M runs, 0 crash) : validation ≡ décision, jamais de signature pour une clé étrangère, toute signature produite vérifie sur leHreçu. Aucun additif couche 1 (tout existait :close_inherited_fds_except,AirProcess::pidfd) ⇒ rien à re-sceller. Point remonté au superviseur : leforksansexecfait hériter à l’enfant l’image mémoire de son ancêtre ; la garantie « la clé privée n’est jamais dans l’espace de l’enfant » n’est totale que si la clé est chargée dans le corps du monitor (donc après le fork de l’enfant), ce que le câblage du démon (V2.5) devra acter — le code le permet et le documente. V2.4 FAIT (AUTHORIZEbranché — le monitor décide, l’enfant ne lit plus rien) :monitor::authorize=MonitorAuthorizer(monitor : résout le compte viaair-account, abaisse son identité effective vers l’uid/gid cible, lit le magasin binaire<home>/.config/air/sshd/authorized_keyssous StrictModes, décide, compte) +MonitorAuthorizedKeys(enfant : implémente le traitAuthorizedKeysen demandant — fail-closed sur canal cassé, EOF ou réponse hors protocole ; applique la commande forcée reçue). StrictModes ajouté au lecteur du magasin (read_authorized_keys_strict, réutilisé aussi parFileAuthorizedKeys— durcissement du chemin non-privsep, sans régression) : descente descripteur par descripteur depuis/enO_PATH|O_DIRECTORY|O_NOFOLLOW(anti-TOCTOU et anti-symlink : rien à substituer entre le contrôle et l’ouverture suivante), propriété ∈ {compte, root} à chaque étage, aucun parent inscriptible groupe/monde (sauf sticky, où nul ne peut remplacer l’entrée d’un autre), magasin privé (0o600) et régulier, borne 1 Mio,EINTRretenté explicitement et borné. Additif couche 1 ADR-127 (couche-1-v3.2, tag NON posé) :air_process::credentials::AirTemporaryIdentity— garde RAII qui abaisse l’identité effective (réel/sauvé jamais touchés ⇒ retour garanti sans capability), vide les groupes supplémentaires (fail-closed : moins-disant que le compte), restaure auDropy compris après panique, et tue le processus si la restauration échouait. Prouvé sous root, dans le monitor réel (tests/authorize_root) : sans abaissement la lecture échoue (le monitor n’a niCAP_DAC_OVERRIDEniCAP_DAC_READ_SEARCH), avec elle réussit, et l’euidest revenu à 0 ensuite ; clé listée ⇒Allow, clé absente / mauvais algorithme / magasin0640⇒Denymuet et indistinct sur le fil. ⚠ Conséquence opérationnelle à traiter avant V2.5 : avec unumask002(groupes privés, défaut courant), unmkdir -pmanuel crée~/.config/air/sshden0775⇒ refusé par StrictModes (comme le ferait OpenSSH — la règle ne sait pas que le groupe n’a qu’un membre). C’est le cas sur carbon :~/.config/airet~/.config/air/sshdsont en0775⇒ unchmod 700des parents est requis sur les hôtes déployés (speedy/carbon/raspi), sinon le chemin privsep refusera. Les répertoires créés parwrite_authorized_keys(CLI d’import) sont en0700et passent. Aucun auto-chmod : un démon privilégié ne réécrit pas les permissions d’un utilisateur dans son dos. §D5 amendé (inexactitude de rédaction) : le magasin lu est le binaire (ADR-073/096), jamais~/.ssh/authorized_keysen clair — le parseur texte OpenSSH ne sert qu’au CLI d’import. ⚠ REMONTÉ AU SUPERVISEUR (optionsauthorized_keys) : le point tranché « options appliquées par le monitor » est câblé au bon endroit (décision +AuthorizeAllow{forced_command}sur le fil), mais le schéma v1 ne portait aucune option ⇒forced_commandétait toujoursNone. Deux décisions ouvertes : (a) étendre le schéma (champ additif) ; (b)from=exige l’adresse du pair, absente d’AuthorizeRequest(V2.1) ⇒ additif de proto, ou renoncement explicite. Aucun contournement improvisé. ✅ ARBITRÉ BDFL 2026-07-28 → ADR-128, livré ci-dessous. OPTIONSauthorized_keysFAIT (ADR-128, arbitrage BDFL 2026-07-28) — les options existent bout-en-bout : schéma → CLI → monitor → réponse. Périmètre (§1) :command="…"+ les 5 restrictions nommées +restricten champ explicite — pas du sucre d’écriture, sans quoi il perdrait sa promesse « toutes les restrictions, y compris futures » et la fidélité d’affichage du CLI ; d’où les 5 ré-autorisations positives (pty,port-forwarding…) et une seule règle de résolution,autorisé(c) = (permit(c) OU NON restrict) ET NON no(c)(fail-closed : le refus explicite l’emporte). Schéma v2 additif (ADR-012) :AuthorizedKey.options @3 :AuthorizedKeyOptions, id neuf ⇒ un magasin v1 reste lisible (pointeur nul = structure par défaut = aucune option) ;versionapplicative à version minimale (1 tant qu’aucune clé ne porte d’option — l’ artefact est alors bit-pour-bit celui de la v1 — 2 sinon) ⇒ un binaire antérieur refuse un magasin contraint plutôt que d’en ignorer la restriction, et continue de lire les magasins sans option ;forcedCommandborné à 4 Kio (parse, écriture et CLI). Généré capnp régénéré + committé, byte-identique. CLI : drapeauxadd --command/--restrict/--no-*/--permit-*, parseur texte OpenSSH étendu à la liste d’options (virgules, guillemets, échappement\", casse indifférente),listréaffiche ce qui a été écrit,importporte les options. Deux gardes issues du principe « zéro perte muette » : options par drapeaux et dans la ligne = refus (fusionner reviendrait à inventer une priorité, donc une politique) ; réajouter une clé connue remplace son entrée (AddOutcome::Replaced) au lieu de laisser la déduplication avaler la nouvelle politique. Monitor :deciderend unAuthorizeConstraint(commande forcée + permissions résolues),AuthorizeAllow{forced_command, permissions}sur le fil — masque d’un octet, tout bit inconnu rejeté (fail-closed : une capacité future qu’on ne saurait pas appliquer ne doit pas être ignorée).from=NON stocké (§5) : il exige l’adresse du pair (absente d’AuthorizeRequest, et non fiable si elle venait de l’enfant hostile) ⇒ refusé à l’écriture avec un message nommé, et livré en V2.5c d’un bloc (champ + adresse renseignée par le monitor + application) — aucune fenêtre de mensonge (ADR-104 §5(b)). ⚠ Ce que cet incrément ne fait PAS : appliquer. Lancer la commande forcée, refuserpty-req/direct-tcpip/x11-reqrelève du worker post-auth (V2.5). La contrainte est décidée et transportée jusqu’au point d’application, jamais présentée comme appliquée. Citation erronée « ADR-096 §6 » corrigée partout (§6 = Production d’artefacts ; la clause exacte est celle des Conséquences), ADR-096 amendé d’un renvoi vers ADR-128. Aucun additif couche 0/1 — tout tient en couche 2. Preuves : round-trip v1 et v2, version minimale prouvée sur l’artefact, les 12 champs d’options exercés un à un, résolutionrestricttable-driven, bornes refusées aux 3 étages, parseur texte (guillemets/échappements/casse/jetons vides/duplicats), CLI (drapeaux, exclusivité, remplacement,list), monitor (masque, bits inconnus, octet manquant), root (clé contrainte ⇒ALLOWdéjà contraint, clé libre du même magasin ⇒ non contraint). Fuzz : décodeur binaire et codec proto inchangés d’API, parseur texte durci d’un 3ᵉ invariant (les options survivent au sceau, etrender_optionsest la réciproque du parseur). V2.5 CONÇU : ADR-129 (conception) fixe la transition post-auth — le monitor forke le worker (setuid + cage selon service : shell=drop-only, SFTP=cage) ; proxy chiffré (l’enfant gardeTransport<S>, tuyau clair passé par SCM_RIGHTS) ;K_Spublic hérité du listener (privé jamais dans l’enfant, §D4) — pas de message HOSTKEY ;from=enforcé au monitor contre l’adresse du pair fournie par le listener (jamais l’enfant) ; le worker applique les options ; contrat mono-fil honoré. Découpage V2.5a (socle proxy/worker + round-trip clair) / V2.5b (service ssh-connection + options + cage sftp) / V2.5c (from=). 1 point remonté BDFL (le listener transmet l’adresse du pair). V2.5a FAIT (socle de la transition post-auth — la mécanique, sans vraie session) :K_Spublic est désormais un type (PublicHostKey) exigé par la signature despawn_privsep, donc matérialisé dans le listener avant tout fork et hérité par l’enfant (§D3) — la clé privée est chargée par le corps du monitor, après le fork. Le monitor crée le tuyau clair (ClearChannel,socketpairSOCK_STREAM), forke le worker (spawn_worker: cascade → purge fd →drop_privilegesvers le compte cible, drop-only §D1 ; un compte résolvant vers root est refusé, faute d’ADR surPermitRootLogin), et passe l’autre bout à l’enfant parSCM_RIGHTSsur le canal RPC (§D2), qu’il relâche aussitôt. Preuves root (transition_root.rs) : l’enfant vérifie une signature contre leK_Shérité alors que la privée n’a jamais existé hors du monitor (clé forgée hors du listener, fichier0600root) ; un octet fait l’aller-retour en clair worker↔proxy à travers la frontière de privilège, le worker étant non-root et incapable de regagner root ; tuer le monitor tue le worker. Additif couche 1 = ADR-130 (couche-1-v3.3, tag non posé) :air-socket(AirUnixStream::pair/read_bytes/write_bytes),air-sandbox(AirSyscall::Recvmsgdanspre-auth/sftp-worker— seul syscall socket du profil, irréductible pourSCM_RIGHTS), et surtoutair-process— correction d’un fail-open livré depuis V2.2 : le noyau effacePR_SET_PDEATHSIGà tout changement d’identifiants, donc « armer puis larguer » laissait chaque étage privsep sans cascade de mort, en silence (le test de cascade V2.2 tuait le monitor avant le fork ⇒ faux vert).drop_privilegesla rétablit avant la cage, avec relecture du parent. 2 points remontés BDFL (cf. ADR-130 §Points remontés). V2.5b FAIT (le worker sert vraiment) : le proxy (proxy.rs) relaie le transport — il ouvre/scelle chaque paquet et ne laisse traverser que la plagessh-connection(80–100), dans les deux sens ; un tag AEAD forgé ne fait passer aucun octet (ADR-032), unEOFd’un côté ferme l’autre. Le worker (worker.rs) parlessh-connectionen clair sur le tuyau, via la même couche connexion que le démon (serve_connection_with+ServiceMode::Deferred), et applique la contrainte (§D5) :command=réécrit shell/exec/subsystem,no-ptyrefuse lepty-req,no-port-forwardingrefusedirect-tcpip/tcpip-forward. Pour le SFTP, il pose la cagesftp-worker(chdirhome → purge fd → Landlock du home + seccomp) avant le premier octet, et sort sans servir si la cage échoue. Découverte structurante : aucun étage privsep ne peut utiliserio_uring(aucunio_uring_*dans les profils ; et un anneau créé avant Landlock échappe à Landlock) ⇒ l’I/O des étages confinés est bloquante (blocking.rs:SshByteStreamsurread/write+ exécuteur inline) et multiplexée parppoll— le protocole, lui, ne change pas d’une ligne (il est générique sur le seam). Nouveau message RPCSTART_SESSION: le monitor ne peut pas distinguer une sonde de clé (RFC 4252 §7) d’une auth réelle, donc forker au premierALLOWlancerait un worker setuid sur une sonde ; l’enfant demande donc explicitement la transition, et le monitor la recoupe avec son propreALLOW(une par connexion). Preuves root (privsep_session_root.rs) : un vrai client OpenSSH exécute une commande de bout en bout à travers proxy+worker et la sortie prouve l’uid du compte cible ;command=s’exécute à la place de celle du client ;no-ptyest prouvé différentiellement (même scénario, avec et sans l’option) ; une sessionsftpliste le home sous la cage et se voit refuser/etc; la cascade de mort survit à la cage (transition_root.rs). Additif couche 1 = ADR-131 (couche-1-v3.4, tag non posé) :air-socket(AirTcpSocket::read_bytes/write_bytes/from_owned_fd),air-sandbox(le profilsftp-workerétait inutilisable — écrit sans appelant, il nommaitopenat/fstatquand la couche 1 n’émet queopenat2/statx; 15 syscalls ajoutés), etair-handle— correction d’un défaut latent depuis ADR-125 :registerfermait le descripteur neuf quand son numéro portait une entrée périmée (fd fermé hors registre par la purgeclose_range), d’où unEBADFsur un fd valide. 3 points remontés BDFL (cf. ADR-131 §Points remontés). V2.5c FAIT (from=, la restriction de source) : champ schéma additiffromPatterns @12surAuthorizedKeyOptions(id neuf, ADR-012 — une entrée écrite avant lui rend une liste vide, soit « aucune restriction ») ; parseur de motifs fuzzé (from_pattern.rs, ciblefuzz_air_sshd_from_pattern) : IP littérale et CIDR v4/v6, négation!, liste séparée par virgules, sémantique OpenSSH (un motif nié qui matche refuse ; sinon il faut un positif — une liste exclusivement négative n’admet personne). Décision de sécurité assumée : les motifs par nom d’hôte (jokers DNS d’OpenSSH) sont refusés à l’écriture, nommément — les honorer ferait dépendre une autorisation d’un reverse-DNS, que l’attaquant contrôle souvent. Deux durcissements fail-loud de plus : un CIDR à bits hôte (10.1.2.3/8) est refusé plutôt que masqué en silence, et un motif IPv6 v4-mapped aussi (les pairs v4-mapped sont normalisés en v4 avant comparaison, comme OpenSSH — sans quoifrom="10.0.0.0/8"ne mordrait jamais sur un client v4 arrivé par un socket dual-stack). Enforcement au monitor (§D4) contre l’adresse du pair du LISTENER — lue à l’accept()(peer_address→from_pattern::peer_ip) et héritée auforkdans la fermeture du corps du monitor :AuthorizeRequestn’a, à dessein, aucun champ d’adresse, donc un enfant compromis n’a rien à falsifier.SessionPolicy::new(host_key, peer)exige l’adresse — un câblage ne peut pas l’oublier. Fail-closed partout : adresse inconnue ⇒ refus ; motif illisible ⇒ refus de la liste entière ; magasin portant un motif illisible ⇒ magasin écarté en bloc ; et le backend aveugle à l’adresse (FileAuthorizedKeys, chemin non-privsep du démon actuel, tant que V2.6 n’a pas câbléserve_forever) refuse toute clé portantfrom=au lieu de l’ouvrir — sans quoi la restriction affichée parlistaurait été une promesse creuse (revue de sécurité, constat bloquant corrigé). Enfin la règle de version minimale (ADR-128 §3) est étendue : un magasin portantfrom=est estampillé3, que les binaires antérieurs (lecteurs{1,2}) refusent en bloc — un retour arrière de binaire coupe l’authentification au lieu de relâcher la restriction qu’il ne sait pas lire. CLI :authorized-keys add --from "10.0.0.0/8,!10.1.0.0/16"accepté,listréaffiche tel qu’écrit, import OpenSSH round-trip. Preuves root (from_restriction_root.rs,privsep_session_root.rs) : la même clé du même magasin ouvre le compte depuis10.2.3.4et est refusée depuis203.0.113.4; l’adresse provient d’unaccept()réel ; la topologie privsep complète rend deux décisions opposées pour une requête d’enfant identique au bit près, et un enfant qui glisse une adresse dans les champs qu’il contrôle ne change rien ; enfin un vrai clientsshpasse avecfrom="127.0.0.0/8"et échoue avecfrom="10.0.0.0/8". Chaque preuve a été vérifiée par mutation (enforcement neutralisé ⇒ tous ces tests tombent). Aucun additif couche 1 :air-socketfournissait déjàpeer_addressetAirIpAddress. V2.6a FAIT (le démon bascule en privsep — le sous-incrément le plus structurant de la vague) : nouveau modulelistener.rs—serve_forever_privsep(listener, PrivsepContext, handle)monte, à chaqueaccept(), la chaînemonitor → enfant pré-auth → workerau lieu de servir en mono-processus.SshdConfig.privilegeSeparationen est le sélecteur (le champ élargit son sens : il valait « larguer avant l’execve», il vaut désormais « la séparation de privilèges » — un déploiement qui l’avait demandée l’obtient, sans avoir à penser à réactiver quoi que ce soit), etSshdSettings::default()le met àtrue: une config neuve décrit un déploiement sûr. (Limite dite : un artefact déjà écrit sans le champ reste en monolithique — un booléen Cap’n Proto absent vautfalse; le rattraper demanderait un champ inversé au schéma.) La clé privée ne touche JAMAIS le listener (ADR-129 §D3, §D4 amendé) : le listener ne tient queK_Spublic, et il l’obtient lui-même hors-processus (public_host_key_from_store: un enfant éphémère lit le magasin, dérive les 51 octets publics, les écrit sur un tube et meurt) — parce queforkcopie toute la mémoire, y compris ce qu’un allocateur a rendu sans l’effacer, et que l’enfant pré-auth est justement le processus supposé hostile. Le monitor charge la clé privée après avoir forké l’enfant, et vérifie qu’elle correspond auK_Sannoncé (une rotation du magasin en cours de route ⇒ refus, jamais une identité annoncée qui diverge de l’identité prouvée). Trois refus de démarrage fail-closed : privsep sans magasin de clés d’hôte (les graines embarquées passeraient par le listener), privsep +authorization = deny(le monitor n’a pas de backend « refus total » — démarrer ignorerait la politique la plus restrictive en silence), privsep + CA de confiance (l’auth par certificat n’est pas portée par le privsep : elle serait refusée sans le dire). CLI complétée :config set host-key-store(elle manquait — la migration ADR-108 était impossible sans passer par le JSON). Décisions de câblage assumées et documentées : le crédit d’admission (throttling ADR-105) est tenu jusqu’à la mort du monitor et non plus jusqu’à l’auth (le listener ne sait plus quand l’auth réussit ; entre « rendre trop tôt » — un flood forke alors trois processus parSYNaccepté — et « rendre trop tard » — un utilisateur est borné en sessions simultanées —, seule la seconde erreur n’est pas une bombe à fork) ; la mort du monitor est sondée avec un délai qui double jusqu’à 1 s, faute d’attente de descripteur générique dansair-async(la voie native reste ouverte, en additif couche 2) ; enfin le journal perd la trace d’authentification (auth_succeeded/auth_failed, ADR-111) — elle a lieu dans l’enfant, dont la cage Landlock deny-all interdit d’atteindre journald, et la faire remonter par le canal RPC ferait écrire le processus privilégié sur la foi de ce que raconte l’enfant : décision d’observabilité renvoyée à V2.6b, dite plutôt que subie. Défaut corrigé, trouvé par le test :monitor::topology::run_monitorattendait son enfant sans relâcher le canal RPC. Tout refus précoce du monitor (clé d’hôte introuvable…) immobilisait donc l’enfant sur une réponse qui ne viendrait jamais — un refus transformé en blocage, le contraire d’un fail-closed. Le canal est désormais fermé dès que le corps du monitor n’a plus rien à dire. Additif couche 2 :air_async::TcpStream::into_owned_fd(réciproque defrom_owned) — sans transfert d’ownership, le seul moyen de confier le socket accepté à l’étage confiné serait de le dupliquer, donc de laisser une poignée de plus sur la connexion dans le processus privilégié. Aucun additif couche 1. Preuves root (daemon_privsep_root.rs, la boucle du démon et non une chaîne montée à la main) : un vraisshobtient unexecsous le compte cible (il imprime sonuid) ; un vraisftpliste le home et se voit refuser/etc(la cage mord) ;from=décide — même clé, même client,127.0.0.0/8passe et10.0.0.0/8refuse, sur l’adresse lue à l’accept(); et le magasin de clés d’hôte retiré après le démarrage ferme toutes les sessions — preuve par soustraction que la clé privée est lue par connexion, dans le monitor, et non mise en cache par le listener. Réserves de revue V2.5b/c CLOSES : (a) isolation des bits d’option enServiceMode::InPlace, sur le vrai chemin et différentiellement —no-pty(terminal accordé vspty-reqrefusé),no-port-forwardingsur les deux portes qu’il ferme (direct-tcpipavec une vraie cible en témoin, et la requête globaletcpip-forward) ; aucun refactor du code de décision ; (b)authorizing_entryresserrée : elle ne rend plus l’entrée mais un candidatAuthorizingEntry, dont la seule sortie estadmit(peer)— la faute naturelle (trouver l’entrée, lire ses options, oublierfrom=) ne compile plus ; (c) CLI durcie : tout drapeau--…inconnu est refusé par verbe (une faute de frappe sur--no-ptyproduisait une clé sans la restriction, avec un message de succès), et un remplacement de clé nomme les restrictions qu’il lève (from="…",command=,no-pty…) au lieu de dire « entrée remplacée ». ✅ RÉSERVE DE SÉCURITÉ CLOSE (2026-07-29, ADR-132, descellement additifcouche-0-v1.15) — voie A retenue :madvise(MADV_DONTFORK)est posé sur les quatre mappings d’anneau à leur création (SQ, CQ, tableau de SQE, et l’anneau de descripteurs des buffers fournis, dans ses deux modes), fail-closed (l’échec du conseil fait échouer la construction du ring). Le processus courant garde ses anneaux — seuls les descendants d’unforkne les reçoivent plus, ce qui est exactement voulu (les étages privsep sont en I/O bloquante, V2.5b). Aucun symbole ajouté ni modifié : le mécanisme (MadviseAdvice::DontFork,madvise) était déjà scellé, seul le comportement demap_ringschange — d’où le descellement. Preuves, vérifiées rouges sans le correctif : (couche 0) trois volets — unMAP_SHAREDsans le conseil est hérité (contrôle négatif qui valide la méthode de mesure), le même avec ne l’est plus (SIGSEGVdans l’enfant, parent intact), et sur un vrai ring l’enfant faute sur la SQ et sur le tableau de SQE pendant que le parent soumet unNOPet moissonne sa complétion ; (couche 2, root)uring_fork_isolation_root.rsmonte la topologie réelle (spawn_privsep) avec un réacteur vivant dans le listener et lit/proc/<pid>/mapsaux trois étages — le listener a ses anneaux (sinon le test serait vacant), le monitor et l’enfant pré-auth ne les ont pas. Tagcouche-0-v1.15à poser par le superviseur. Historique de la réserve (cf.notes/reserve-anneaux-io-uring-herites-au-fork-fr.md) : les anneauxio_uringsont mappésMAP_SHAREDet survivent aufork— un enfant pré-auth compromis garde donc une fenêtre en écriture sur la SQ/CQ du listener root, et peut lui faire soumettre des opérations arbitraires. ADR-122 §5 traitait l’anneau hérité par la fermeture du descripteur ; cela empêche l’enfant de soumettre, mais ne démappe rien. V2.6a n’aggrave rien (avant elle, la session entière tournait dans le listener root) mais n’achève pas la séparation. Fermeture recommandée :madvise(MADV_DONTFORK)sur les trois mappings à la création de l’anneau — mécanisme déjà présent en couche 0 (MadviseAdvice::DontFork), aucun usage légitime cassé, mais descellement de couche 0 à décider (ADR-015). Non fait ici : hors mandat de ce sous-incrément — fait depuis par ADR-132 (ci-dessus). V2.6b FAIT (complétude de session sous privsep — les trois différés de V2.5b/V2.6a) : ① le rekey traverse le proxy. UnSSH_MSG_KEXINITen pleine session n’est plus fatal : c’est le début d’un échange de clés, et l’échange de clés appartient à l’étage qui détient les clés — le proxy. Les deux sens sont câblés : le client demande (le proxy répond et mène le KEX), ou le proxy décide seul quand sa fenêtre de volume est atteinte — c’est cette moitié-là qui garantit le nonce AEAD (numéro de séquenceu32), et la déléguer au bon vouloir du pair reviendrait à ne pas l’avoir. La signature du rekey est demandée au monitor comme au premier KEX (le canal RPC reste ouvert post-transition) : le proxy n’a jamais queK_Spublic, et aucun octet de KEX n’atteint le worker. Trois règles tiennent la correction : après notreKEXINIT, plus rien de la couche connexion ne part vers le client (RFC 4253 §7.1 — le worker se bloque sur son tuyau, rien n’est bufferisé ni perdu) ; avant que le pair ait vu notre offre, ce qu’il envoie encore est relayé normalement ; et on ne se rendort jamais sur un paquet déjà décodable — un rekey interrompt le vidage du décodeur, un pair colle volontiers sonNEWKEYSfinal aux données qui suivaient, et ces octets-là étant déjà lus, aucunppollne les annoncerait (défaut trouvé par la suite de tests sous charge, épinglé depuis par un test déterministe qui colle les deux paquets en une écriture ; sans le vidage, la session s’endort jusqu’au prochain octet du client — qui peut ne jamais venir). Reste hors périmètre, dit : le rekey par le temps (il faudrait unclock_gettimedans une cage qui n’en veut pas ; le volume couvre seul la propriété de sûreté). Additifstransport:send_kexinit/accept_kexinit(les deux moitiés d’exchange_kexinit),complete_kex_with_signer,rekey_responding_with_signer,with_rekey_thresholds(fenêtre explicite — sans quoi un rekey déclenché par le serveur ne serait pas prouvable sans échanger un gibioctet). ②SSH_ORIGINAL_COMMAND. Souscommand="…", la demande du client est exposée au processus forcé (jamais exécutée) — c’est ce qui rend possibles les dispatcheurs (gitolite,rrsync) sans distribuer une clé par commande. Unexecexpose sa ligne, unsubsystemson nom, unshellrien (convention OpenSSH). Câblé sur les deux chemins (worker privsep et démon monolithique). ③ La piste d’audit d’auth, rendue au monitor (la décision d’observabilité que V2.6a avait explicitement renvoyée ici) : le monitor est le seul étage à la fois privilégié et décisionnaire. Refus → à l’AUTHORIZErefusé (sa décision, prise en lisantauthorized_keysen tant que l’utilisateur) ; succès → à la transition accordée, pas à l’ALLOW— parce qu’un client peut sonder une clé sans la signer (RFC 4252 §7) et qu’une sonde emprunte le mêmeAUTHORIZE: journaliser un succès là écrirait qu’on s’est authentifié avant toute signature. Chaque champ vient de la source qui fait foi (adresse du listener, compte accordé par le monitor, méthodepublickey). Émission best-effort (un journal ne doit ni ouvrir ni fermer une session) et jamais silencieuse :oplog::operational_logdégrade vers stderr si journald ne répond pas — une trace d’auth absente se lirait « personne ne s’est connecté ». Aucun additif couche 0/1. Preuves, toutes vérifiées rouges sans leur correctif :tests/proxy_rekey.rs(hors root, rôle client réel sur le cœur) — rekey demandé par le client et rekey déclenché par le serveur,session_idconservé, session vivante avant/après, le worker attestant n’avoir vu que la couche connexion, et le paquet collé auNEWKEYS(tuyau client qui retient ses écritures et les pose d’un bloc, puis se tait : si le relais attend un réveil réseau, il l’attend pour toujours) ;privsep_session_root.rs— un vraissh -o RekeyLimit=16Ktransfère ~250 kio à travers la chaîne complète etSSH2_MSG_NEWKEYS received≥ 2 (l’assertion qui compte : une première version du test se contentait deKEXINIT sent ≥ 2et passait sans le correctif, un client pouvant demander un rekey en toute fin de session et sortir sans réponse) ;SSH_ORIGINAL_COMMANDprouvé différentiellement (la commande demandée est lue et n’est pas exécutée, et la variable est absente hors commande forcée) ; l’audit relu dans journald (AIR_USER/AIR_PEER_IP/AIR_METHOD), une seule ligne de succès par session (la sonde n’en produit pas) et unfrom=refusé produit un échec et aucun succès. Restant vague 1 = campagne de tests de sécurité (flood/DoS/brute-force/rejeu/ timing), redirections de port en privsep (le worker n’a pas de réacteur pour porter un pump concurrent — refusées, pas servies à moitié), et le sort de l’auth par certificat sous privsep (message RPC dédié ou certificats hors privsep) = clôture vague 1. Durcissement B2 FAIT — ADR-133 (quota de signatures du monitor) : ferme l’observation non bloquante de la revue de sécurité adverse de la PR #511. La validation du monitor étant structurelle (ADR-124 §D4, tranché BDFL — longueurs 32/48/64 o, cohérence des KEXINIT, égalité deK_S, sans recompute deH, impossible sans le secret DH qui reste dans l’enfant), un enfant pré-auth compromis dispose d’un oracle de signature Ed25519 sous la clé d’hôte sur un hash quasi arbitraire — et il ne l’exerce pas au rythme des rekeys : il spamme des tramesSIGNsur le RPC, au débit du seul round-trip. V2.6b n’a pas créé l’oracle (il préexiste à ADR-122/124) mais en a allongé la durée (toute la session, le canal RPC restant ouvert pour re-signer aux rekeys). Décision BDFL : double quota fail-closed par connexion dansHostKeySignPolicy::sign— plafond absolu 1024 signatures + token-bucket rafale 8 / recharge 2 par seconde, évalués avant la validation (le moins cher d’abord), chaque refus restant unSignRefusednu. La primitive de seau est extraite du throttling pré-auth (module internetoken_bucket) : une seule implémentation du calcul de recharge, deux usages (throttle.rsla garde, keyée par source). Couche 2 pure : aucun descellement, aucun tag. Preuves, toutes vérifiées rouges sans leur correctif — ① la mesure du finding par un harnais d’enfant simulé compromis parlant le vrai codec RPC : 4096 signatures récoltées sans quota (soit 4 × le plafond, en ~1 s), 8 avec le quota ratifié, sur les mêmes 4096 trames ; ② le plafond refuse fail-closed et ne se recharge jamais ; ③ le débit refuse au-delà de la rafale puis ré-autorise après recharge (temps injecté, commethrottle/tests.rs) ; ④ non-régression — KEX initial + 60 rekeys (un par minute, une heure) et une salve de 8 rekeys rapprochés : zéro refus ; ⑤ mutisme — un refus de quota est octet pour octet le même que le refus de validation sur le fil. Les 8 axes privsep de la campagne (dont B2, la mesure de cet oracle) restent différés à la 2ᵉ campagne complète (décision BDFL 2026-07-29) ; seule cette mesure est menée dès maintenant, en preuve. Déblocage du déploiement 0.3.0 FAIT — ADR-134 (CLI de migration de la clé d’hôte vershostkeys.aircfg) : le démon 0.3.0 démarre en privsep par défaut et refuse (fail-closed, ADR-129 §D3) les graineshost_key_seedsdusshd.aircfg— lues par le listener, or la clé privée ne doit jamais s’y trouver — mais aucune commande ne savait produire le magasinhostkeys.aircfg(ADR-108) que le privsep exige : les machines déployées en 0.2.1 étaient donc bloquées. Nouveau verbeair-sshd migrate-host-key-store <chemin-absolu> [--config <sshd.aircfg>] [--force]: il scelle la clé EXISTANTE (mêmes graines ⇒ même paire Ed25519 ⇒ même empreinte : aucun avertissement « host key changed » sur la flotte), câblehost_key_store_path, et vide les graines — c’est le point de sécurité. Ordre critique : le magasin0600est écrit avant toute réécriture dusshd.aircfg⇒ jamais de config à moitié migrée. Logique en bibliothèque (air_sshd::host_key_migration), le binaire ne collecte qu’argvet l’horloge. Couche 2 pure : aucun descellement, aucun tag. Preuves (rouges sans le correctif) : round-trip magasin0600décodable à clé publique identique + graines disparues ; idempotence (« déjà migré »,--forceexigé pour écraser un magasin non vide) ; fail-closed (rien à migrer ⇒ erreur ; magasin non écrivable ⇒sshd.aircfgintact) ; identité préservée ; et le test root de bout en bout sur le vrai binaire — unsshd.aircfgà graines fait fail-closer le démon, puis, après migration, la chaînelistener → monitor → enfant → workersert une sessionsshréelle (KEX signé par le monitor depuis le magasin migré, clé autorisée,exit-statusrendu). ⚠️→✅ Défaut découvert par cette preuve, HORS ADR-134 — CORRIGÉ par ADR-135 : sous le binaire (et non sous la boucle in-process dedaemon_privsep_root), le processus lancé par le worker mourait avant sonexecve—air_handle::duplicaterefermait le descripteur neuf lorsque le registre portait une entrée périmée pour ce numéro (registry.rs,table.insert(...)dont l’ancienne valeur était droppée au lieu d’être oubliée) ; les entrées héritées puis purgées par leclose_rangepost-forkd’ADR-125 §D8 en laissent. C’était le pendant, dansduplicate, du défaut qu’ADR-131 a corrigé dansregister. Conséquence : une session privsep aboutissait (clientsshà 0) mais n’exécutait rien. Aucun test n’exerçait la topologie privsep complète depuis le binaire livré — d’où la découverte tardive.🟡 EN COURS — ADR-146 : LE FORWARDING SERVEUR SOUS PRIVSEP (V2.6-1 → V2.6-3). Le constat qui l’ouvre : depuis V2.5b, une session privsep refusait toute redirection — le worker n’a ni réacteur, ni
socket(2), niconnect(2), et servir à moitié n’était pas une réponse. La conception (ADR-146, #577) tranche l’option B : le worker demande, le monitor décide, connecte, et remet le descripteur (SCM_RIGHTS). Le worker ne gagne aucun droit — il reçoit un socket déjà connecté, etread/writene sont passocket/connect.
- V2.6-1a — le canal RPC
ForwardConnect(MERGÉ #579). Un second socketpair SEQPACKET worker↔monitor, créé après leforkde l’enfant pré-auth (qui ne le voit donc jamais) et survivant à la purge de descripteurs. Deux types, pas un canal générique :ForwardRequester(worker) n’expose qu’une opération,ForwardMediator(monitor) traite tout autre message comme une violation de protocole qui ferme le canal. Le monitor rejoue toute la politique sur sa propre mémoire (AllowTcpForwarding,PermitOpen,no-port-forwardingd’ADR-128) et ne résout que de l’IPv4 littérale (aucun DNS dans le privilège : il ferait décider un tiers de ce que root va joindre). Refus sans motif (ForwardRefusedn’a pas de champ) : pas de scanner du réseau interne. Canal empoisonné à la moindre désynchronisation. Preuve root (forward_connect_root.rs) : un worker non-root, purgé, largué, joint une cible qu’il n’aurait pas pu ouvrir et relit son écho ;AllowTcpForwarding noet une destination horsPermitOpenl’arrêtent — et la cible ne voit aucune connexion.- V2.6-1b —
ssh -LSERVI (ce jalon). Le câblage : la boucle connexion, en mode différé, ne refuse plus undirect-tcpip— elle rend unConnectionOutcome::Forward(cible en octets, non résolue ; aucune confirmation émise, puisque personne n’a encore joint quoi que ce soit). Le worker demande au monitor, puis confirme, puis pompe — dans cet ordre, parce qu’uneOPEN_CONFIRMATIONdit « ta cible est jointe ». Nouvelle pompe bloquante (forward_blocking.rs) : un seulppollsur deux descripteurs, aucun io_uring, aucun fil (la cage n’en veut pas — cf.blocking.rs) ; contrôle de flux dans les deux sens (fenêtre reçue consommée puis recréditée après écriture réelle sur la cible, fenêtre d’émission etmaximum_packet_sizedu client jamais dépassés, dépassement de nos bornes annoncées = canal fermé) ; cible enO_NONBLOCKavec file d’attente bornée par la fenêtre annoncée — c’est ce qui empêche l’interblocage d’un relais mono-fil. Côté worker, la garde ne vérifie que la permission de la clé : la politique du serveur est l’affaire du monitor, seul à en détenir une version opposable. Refus uniforme (politique, hôte irrésoluble,connecten échec : le mêmeCHANNEL_OPEN_FAILURE). Un forward = fin de session (comme le monolithique). Preuves : unitaires (l’outcome est rendu / refusé, et rien n’est promis avant la médiation), pompe (aller-retour intègre, fenêtre et taille de paquet respectées, violation →CLOSE,EOFpropagé, tuyau rompu = fin normale), et e2e root — un vraissh -N -Ltraverselistener → monitor → proxy → worker, les octets reviennent intacts d’un écho local, et la cible confirme avoir servi exactement une connexion ;AllowTcpForwarding noet un monitor sans politique armée (défaut fail-closed) l’arrêtent, cible muette.- V2.6-2a — la PLOMBERIE du
-R: le monitor écoute et accepte (ce jalon). La médiation inversée, et le canal devient bidirectionnel : le monitor peut désormais émettre un message non sollicité, parce que c’est un tiers — le client qui se connecte au port redirigé — qui provoque l’événement. Trois verbes de plus (ForwardListen/ForwardListening|ForwardListenRefused,ForwardCancel/ForwardCancelled) et une poussée (ForwardAccepted{origine, port lié}, accompagnée du socket accepté parSCM_RIGHTS). Le monitor reste l’autorité : même ordre d’étages que la garde monolithique (permits_remote→ permission de la clé →PermitListen), refus uniforme (politique, bind impossible, quota : indiscernables). Le socket d’écoute ne sort jamais du monitor — seuls les acceptés traversent, donc le worker n’acquiert nibind, nilisten, niaccept. Les écoutes entrent dans le mêmeppoll(pas de fil, pas d’anneau) ; la contre-pression est celle du noyau : avant chaqueaccept, le monitor demande si le canal du worker accepterait la poussée — sinon il n’accepte pas, et la connexion attend dans le backlog. Rien ne s’accumule dans le processus privilégié, qui ne bloque donc jamais sur un worker lent. Ni sur une écoute (correctif de revue de sécurité) : les sockets d’écoute sont non bloquants (O_NONBLOCKposé après lelisten, fail-closed — si la pose échoue, l’écoute est refusée), et l’écoute prête est désignée par un nom stable (ListenerId) et non par son rang. Sans ces deux gardes, unForwardCancelservi dans le même réveil qu’une connexion entrante décalait les rangs et faisait dormir root dans unaccept(2)sur l’écoute voisine — toute la session figée. BornageMAX_REMOTE_LISTENERS(8) et fauchage de toutes les écoutes à la fin de session (y compris sur violation de protocole). Côté worker, la discipline de lecture est un point unique classant par étiquette, avec file de poussées bornée : une poussée arrivée pendant l’attente d’une réponse ne peut pas être confondue avec elle. Preuve root (forward_listen_root.rs) : un worker non-root, purgé, largué, sert une connexion entrante sur un port qu’il n’aurait pas pu lier ; les trois étages de politique l’arrêtent, aucun port lié. Non câblé dans la garde-Rà ce stade — c’est le jalon suivant qui l’y branche.- V2.6-2b-i —
ssh -RSERVI, en séquentiel (ce jalon). Le câblage de la plomberie 2a : la garde-R, en mode différé, ne refuse plus untcpip-forward— elle rend unConnectionOutcome::RemoteForward(bind en octets, non résolu ; aucune réponse émise, puisque personne n’a encore lié quoi que ce soit — et que leREQUEST_SUCCESSd’un bind éphémère doit porter un numéro de port qui n’existe pas encore). Ce bras passe avant la politique du serveur, structurellement : le worker n’en détient qu’une version non bornée, et la consulter donnerait l’illusion d’une décision. Le worker demande l’écoute, puis répond, puis sert — et pour chaque connexion que le monitor lui pousse, il ouvre un canalforwarded-tcpipvers le client (adresse connectée = le port réellement lié ; origine = celle que le noyau a constatée à l’accept4, jamais déclarée) et passe la main à la pompe de 1b/1c, réutilisée telle quelle — pas une seconde pompe « inspirée de » qui aurait perdu le jour même le contrôle de flux, la garde anti-POLLHUP-tournant et le drainage du résidu avant l’EOFque deux revues de sécurité ont durcis. Séquentiel assumé : une connexion servie à la fois, et rien ne s’accumule nulle part — pendant qu’une pompe tourne le worker ne draine pas son canal RPC, donc le monitor cesse d’accepter (contre-pressionPOLLOUTde 2a) et les entrantes attendent dans le backlog du noyau. Uncancel-tcpip-forwardest relayé au monitor (lui seul détient le socket) et termine la session ; la fin du transport, la mort de la médiation et une pompe qui constate le tuyau rompu la terminent aussi — dans les quatre cas, le canal RPC se ferme et le monitor fauche l’écoute. Preuves : unitaires (l’outcome est rendu / refusé, et rien n’est promis avant l’écoute), boucle sur vraisForwardMediator/ForwardRequester(deux connexions successives servies, refus d’ouverture → tiers fermé et écoute intacte, annulation → écoute lâchée par le monitor), e2e privsep (remote_forward_root.rs: la vraierun_sessiondans un worker forké non-root) et surtout e2e client réel — un vraissh -N -Rtraverselistener → monitor → proxy → worker, un tiers se connecte au port que root a lié, ses octets ressortent chez le client et reviennent intacts ;AllowTcpForwarding no,PermitListenetno-port-forwardingl’arrêtent, aucun port lié.- V2.6-2b-ii —
ssh -RCONCURRENT (ce jalon). Le séquentiel devient un démultiplexeur : N canauxforwarded-tcpipvivants sur le même transport, pilotés par un seulppoll(tuyau clair + canal de médiation + N cibles) dans un seul fil. Le geste central n’est pas la boucle, c’est la factorisation : la pompe durcie de 1b/1c (anti-spinPOLLHUP, drainage du résidu avant l’EOF, contrôle de flux dans les deux sens) est extraite en pilote PAR CANAL — lePumpporte désormais son proprelocal_id, exposeon_client_message/on_target_ready/target_interest, et ne possède ni la boucle ni le décodeur du transport.pump_direct_tcpip(-L) en devient l’appelant mono-canal, un mince enrobage : les douze tests d’invariant deforward_blockingsont restés verts sans une ligne de modification — c’est la preuve que l’extraction a déplacé du code sans en réécrire la sémantique. Le démux route chaque message parrecipient_channel, ouvre sans attendre (CHANNEL_OPENpuis état pending, la réponse est traitée comme n’importe quel autre message — là où 2b-i bloquait sur la confirmation), et fauche les canaux finis. Trois bornes : 32 canaux simultanés (au-delà, le worker cesse de demanderINsur son canal RPC, la contre-pressionPOLLOUTdu monitor prend le relais et les tiers patientent dans le backlog du noyau), une fenêtre bornée par canal, un tableau deppollsur la pile. Identifiants locaux monotones, jamais recyclés : un message tardif ne peut pas s’appliquer au tiers suivant. Amélioration au passage :cancel-tcpip-forwardretire le port sans tronquer les canaux ouverts — la session s’achève avec le dernier d’entre eux. Preuves : les invariants deforward_blocking(12), le démux sur vraisForwardMediator/ForwardRequester(deux tiers servis de front, octets entrelacés dans les deux sens et jamais mélangés ; la fermeture de l’un n’atteint pas l’autre ; un refus d’ouverture non plus ; la borne de 32 retient puis relâche ; la fin de transport ne laisse aucun descripteur), e2e privsep root (remote_forward_root.rs: deux tiers concurrents à travers un worker forké non-root qui n’a jamais appeléaccept4) et e2e client réel (privsep_session_root.rs: quatre tiers de front derrière un vraissh -N -R, avec une cible d’écho qui accepte les quatre avant d’en servir un seul — un serveur qui sérialiserait ne renverrait jamais le premier écho).- V2.6-3 C.5a — le
connect(2)sortant devient ASYNCHRONE côté monitor (ce jalon). Le sens-Lavait gardé, seul, un syscall bloquant dans la boucle du processus root : leconnect. Vers une adresse qui laisse tomber le SYN — pas un port fermé, qui rend unRSTimmédiat, mais une IP muette —, il y dormait le temps que le TCP abandonne, deux minutes environ pendant lesquelles la session entière était suspendue : plus de service pré-auth, plus d’acceptsur les écoutes-Rtenues, plus de récolte du worker. Et il suffisait, pour l’atteindre, d’unssh -Lvers une IP injoignable — c’est-à-dire d’une demande que la politique peut parfaitement autoriser (finding tracé de la revue de #582). La médiation se fait désormais en deux temps, exactement comme le sens-Rle fait déjà pouraccept:serve_onceapplique la politique — inchangée, même ordre —, lance unconnectnon bloquant et enregistre un connect en cours sans répondre ; le descripteur rejoint leppollunique du monitor, surveillé en écriture (c’est ainsi qu’unconnectnon bloquant annonce sa fin), etfinish_connectconstate l’issue pargetpeername(2)— une adresse de pair est l’établissement, son absence est l’échec — puis répond, descripteur en tête. Trois propriétés portent la sécurité : borne (MAX_PENDING_CONNECTS = 8, en queue de politique comme le quota d’écoutes ; au-delà, refus sans motif plutôt qu’accumulation), fauchage (RAII : la fin de session ferme les connects en vol comme elle ferme les écoutes) et refus uniforme (politique, résolution,connectperdu d’avance et échec constaté rendent tous le mêmeForwardRefused— aucun oracle de joignabilité de plus qu’avant). Le worker est INCHANGÉ :request_forward_connectenvoie et attend comme avant ; l’attente est simplement devenue la sienne seule. Ce jalon est aussi le prérequis de la coexistence C.5 — un-Ldans un démultiplexeur ne doit pas figer les autres canaux. Additif couche 1 minimal :air_socket::engine::is_connect_in_progress(une seule définition d’EINPROGRESSdans le crate, partagée avecconnect_timeout). Preuves : le découpage lui-même (servir la demande laisse un connect en cours et n’a rien répondu), une cible muette reproduite sur la boucle locale (file d’écoute pleine → SYN abandonné, sans dépendre d’une route absente) qui établit que le connect reste en vol et qu’une seconde demande est servie pendant ce temps, la boucleservecomplète (unForwardListenet l’enfant pré-auth servis alors qu’un connect est en vol, sous délai borné — unconnectbloquant y tiendrait bien au-delà), la borne, le fauchage, le socket rendu au worker redevenu bloquant (cible délibérément silencieuse : un socket non bloquant y rendraitEAGAIN), et la non-régression e2e root (forward_connect_root).- V2.6-3 C.5b — le forwarding est UNIFIÉ :
-L+-R+ écoutes multiples, ensemble (ce jalon). Le worker servait jusqu’ici soit un-L(une pompe mono-canal), soit un-R(le démultiplexeur), jamais les deux, et un seul de chaque : deux chemins pour ce qui, une fois la cible en main, est le même canal. Le démux devient le chemin de forwarding, et porte désormais les deux espèces dans la même table, sous le mêmeppollet le même pilote [Pump] — d’où : un-Let un-Ractifs en même temps, plusieurs-Lsuccessifs sur une session, plusieurs écoutes simultanées (une poussée est routée vers son écoute par lebound_portque le monitor lui joint).serve_direct_tcpipetserve_tcpip_forward_listendisparaissent au profit d’unserve_forward_demuxamorcé par l’un ou l’autre premier évènement (ForwardSeed) ; le-Lmono-canal (pump_direct_tcpip) est supprimé, et ses douze tests d’invariants — flux dans les deux sens, garde anti-POLLHUP-tournant, drainage du résidu avant l’EOF— sont rejoués à travers le démux, inchangés. Le-Ly est asynchrone (acquis de C.5a : le fil ne dort plus sur unconnecttiers) et sérialisé — une demande en vol, les suivantes en file bornée : le protocole de médiation ne porte aucun identifiant de corrélation, et deux réponses identiques ne se distingueraient pas (la concurrence par corrélation est une optimisation, pas un prérequis). Côté canal RPC, une réception unifiée (receive_message→ connexion accordée / refusée / poussée) classe par étiquette, jamais par ordre d’arrivée : une réponse de-Lqui arrive pendant qu’une demande d’écoute attend la sienne est rangée, pas confondue. Bornes : 32 canaux (une ouverture-Len vol réserve sa place — sans quoi le socket qu’on nous rendrait n’aurait plus où aller), 16 ouvertures en file, 8 écoutes ; une connexion poussée que la table ne peut pas porter est mise en réserve (et la lecture du canal RPC cesse) plutôt que jetée. Pas de session ici : unCHANNEL_OPEN sessionest refusé — c’est C.5c, dont ce démux est le socle. Preuves : coexistence-L+-R(les deux canaux vivants ensemble, octets intacts dans les quatre sens, aucun croisement), sérialisation (le monitor ne voit pas la seconde demande tant qu’il n’a pas répondu à la première, et elle est servie ensuite), deux écoutes simultanées (chaqueCHANNEL_OPENnomme son bind ; annuler l’une laisse l’autre), refus de politique-Lsans toucher au-R, refus de tout ce qui n’est pasdirect-tcpip(session,streamlocal, inconnu) sans rien demander au monitor, permission de clé opposée aux ouvertures suivantes (et gratuitement), les 12 invariants du pilote rejoués via le démux, les 14 tests-Rde 2b-ii inchangés, et — repli de la réserve de la revue #585 — la branche fail-closed definish_connectprouvée de façon déterministe : un connect qui n’a pas abouti rendForwardRefusedet aucun descripteur ne franchit la frontière de privilège.- V2.6-3 C.5c — la SESSION rejoint le démux : un shell et un
-L/-Rsur la même connexion (ce jalon). Jusqu’ici la session possédait le transport :serve_interactiveetserve_programtenaient chacun leur propre boucleppoll, du premier octet du shell à sonexit-status, et une redirection sur la même connexion était donc structurellement impossible — deux boucles ne partagent pas un descripteur. Le maître du PTY (ou le socketpair d’unexec) devient un descripteur de plus dans leppolldu démux, à côté des cibles des redirections, et le canal de session est routé comme les autres, par son identifiant local. Le dialogue de session, lui, ne bouge pas d’une ligne : il est extrait tel quel danssession_blocking::SessionPump(mêmes lectures, mêmeSIGHUPà la fermeture du client, mêmewindow-change, même ordre de clôtureexit-status→EOF→CLOSE, même fermeture gracieuse par drainage du pair). L’inversion que porte l’incrément : la sortie du shell ne ferme plus la connexion, elle clôt son canal, qui quitte la table — le transport et les redirections décident de la fin. La décision d’une requête de session (no-pty,command="…", sous-système hors liste blanche) est extraite en un seul endroit (connection::decide_session_request) et opposée par les deux étages : une option d’authorized_keysqui ne fermerait sa porte que dans l’un ne la fermerait pas du tout. Amorçage :Service{Shell/Exec}entre dans le démux avec un canal de session déjà lancé (seul le worker connaît le compte, son home et son shell — d’oùSessionLauncher, prêté au démux pour les sessions qui naissent en cours de connexion) ; unCHANNEL_OPEN sessionreçu dans le démux ouvre, négocie et lance de la même façon. Périmètre assumé : le SFTP garde son chemin dédié (serve_sftp_caged, cage posée avant de servir, transport possédé) et est refusé s’il est demandé dans le démux — c’est C.5d ; une seconde session reste refusée, du refus de toujours (« only one session channel »), y compris après la sortie du shell. Preuves : e2e root avec un vrai client OpenSSH —ssh -L … compte@hôte 'commande'(sans-N) obtient les deux, octets du tunnel intacts pendant que la commande tourne, sortie et code de sortie de la commande rendus au client ; en unitaire, une session ouverte dans le démux pendant qu’un-Lsert (et le tunnel survit à la sortie du shell), une redirection qui rejoint une session déjà en cours (et qui n’hérite pas de son identifiant0), le refus d’une seconde session, le refus du SFTP, etno-ptyopposé par le démux ; côté pilote, sortie et code de sortie intacts, ordre de clôture, idempotence de la clôture,EOFclient qui ne clôt rien. Non-régression : les 15 testsprivsep_session_root(shell,exec, PTY,no-pty, commande forcée, SFTP cagé, rekey, audit) restent verts.- V2.6-3 C.5c-fix — le canal de session ne peut plus geler ses redirections (revue de #589). Deux endroits où le pilote de session immobilisait le fil unique du worker — auto-déni de service borné à la connexion de l’attaquant, mais un canal qui peut figer ses voisins n’est pas un canal qui coexiste. (1) La récolte était bloquante : un
CHANNEL_CLOSEmenait àSIGHUPpuis à unwaitsans borne, dont un processus qui piègeSIGHUP(trap '' HUP) ne sortait jamais. Le pidfd du processus (déjà exposé parair_process::AirProcess—clone3+CLONE_PIDFD) entre désormais dans leppolldu démux : lisible = mort, ettry_wait(WNOHANG)rend la main aussitôt. UnCHANNEL_CLOSEne fait plus que signaler et marquer le canal ; s’il ne meurt jamais, le canal reste semi-fermé et les redirections continuent d’être servies. Le pidfd quitte le tableau dès la récolte (un pidfd fauché reste lisible : le laisser ferait tourner l’attente à 100 %). (2) L’écriture vers le processus était bloquante : un client qui inonde l’entrée d’un shell qui ne lit pas remplissait le tampon du terminal, et lewrite(2)suivant s’asseyait. Maître PTY et socketpair sont non bloquants, ce qui ne part pas attend dans une file bornée par la fenêtre annoncée, le descripteur est surveillé enPOLLOUTtant qu’elle n’est pas vide, et le crédit n’est rendu qu’après écriture — le canal de session gagne ainsi le contrôle de flux qu’il n’avait jamais eu, celui deforward_blocking. Sémantique inchangée :exit-status(vrai code, une fois), ordreexit-status→EOF→CLOSE,SIGHUP,window-change, PTY. Preuves : un processus qui piègeSIGHUP+ un-Lactif → le tunnel relaie après leCHANNEL_CLOSEde session ; un client qui inonde l’entrée d’un shell PTY + un-Lactif → le tunnel relaie pendant ; sortie normale →exit-statuscorrect par la récolte pidfd, une seule fois, et rien après.- V2.6-3 C.5d — le SFTP coexiste, cage comprise : la DERNIÈRE pièce de C.5. Le sous-système était le dernier service à posséder le transport, pour une raison qui n’était pas la sienne : il se sert cage posée ([ADR-129] §D1), et une cage ne se posait pas depuis une boucle tenant déjà des canaux. Le dialogue SFTP est désormais un pilote de canal (
sftp_blocking::SftpPump, même forme queSessionPump/Pump), sans processus ni descripteur : il ne vit que des données du client, ingère au routage et travaille au tour de boucle. Le cœur (air-sftp-proto) et le moteur (sftp::SftpEngine) sont inchangés — c’est la condition de la non-régression. Ce que le pilote gagne : un contrôle de flux dans les deux sens sans jamais attendre (fenêtre de réception opposée, crédit rendu après consommation ; file de sortie bornée, jamais au-delà de la fenêtre du pair — la correction S.5 reprise telle quelle). La cage est posée soit par le worker (SFTP premier service) soit par le démux (SFTP demandé alors que des redirections tournent), toujours avant le premier octet servi, et sa purge préserve exactement le tuyau clair, le canal de médiation et les cibles des redirections déjà ouvertes ; un PTY éventuellement alloué est relâché avant la purge (double fermeture). Échec de pose = fail-closed : sous-système refusé et connexion terminée (EXIT_CAGE_FAILED). Le profilsftp-workern’est PAS élargi : tout le chemin de redirection (ppoll,read/write,recvmsg,fcntl,close) y est déjà ; les deux seuls gestes qui n’y sont pas —shutdown(2)et leMSG_PEEKde sondage de résidu (recvfrom(2)) — sont supprimés sous la cage plutôt que tentés (l’action par défaut du profil estKILL_PROCESS), au prix documenté d’une demi-fermeture différée et d’un doute résolu du côté conservateur. Les ajouter au profil (tous deux dominés par lesclose/recvmsgdéjà permis) est une décision de politique de sécurité — laissée au BDFL, cf. l’en-tête deforward_demux_blocking. Preuves : en unitaire, une session SFTP cagée et un-Lactifs ensemble (dialogue SFTP pendant que le tunnel relaie, dans les deux sens), la liste des descripteurs préservés vérifiée descripteur par descripteur, le tunnel qui relaie après la pose de la cage, la cage refusée qui refuse le sous-système et termine la connexion, 13 tests du pilote SFTP ; en e2e root,sftp(1)+-o LocalForwardsur une connexion — le home listé,/etcrefusé (la cage mord toujours), les octets qui traversent le tunnel, et un monitor qui rend0(un worker tué par sa propre cage se verrait là).- V2.6-3 streamlocal-L —
ssh -Lvers un SOCKET UNIX, joint SOUS L’IDENTITÉ DE L’UTILISATEUR (MERGÉ #596). Undirect-streamlocal@openssh.comn’est plus refusé en privsep : il devient un canal-Lcomme un autre — même file d’ouvertures, même sérialisation, même pilotePump, même pompe. La seule ligne qui distingue les deux formes est le verbe de médiation émis : nouveau RPCForwardConnectUnix(tag requête8, un unique champ borné parsun_path— 107 octets, la même constante que le pilote monolithique ; au-delà la couche 0 tronquerait, donc on refuse sur le fil), réponsesForwardConnected/ForwardRefusedinchangées, fuzzé dansfuzz_air_sshd_monitor_proto. Le cœur est côté monitor : la politique d’abord (AllowTcpForwarding(-L)+ permissionno-port-forwardingde la clé — la même porte quedirect-tcpip; puisPermitOpendans sa seule lecture honnête pour un chemin : dès que la liste borne, refus, mot pour mot ce que fait la voie monolithique — sans quoi le privsep serait plus permissif que le chemin monolithique sous la même configuration. UnePermitOpende chemins reste un incrément possible ; quand la liste ne borne pas, la contrainte d’accès est l’identité empruntée, d’une autre nature et plus fine qu’une liste), puis, dans une portée étroite sousair_process::credentials::AirTemporaryIdentity::assume(uid, gid):socket(AF_UNIX), non bloquant,connect— et rien d’autre. La garde restaure root à sonDrop, y compris sur erreur ou panique ; son contrat mono-fil est satisfait par construction (le monitor est une boucle bloquante mono-fil, et le type est!Send). Le connect en cours est ensuite poursuivi en root dans leppollexistant (getpeername), avec la même file et la même borneMAX_PENDING_CONNECTSque le sens TCP : la boucle root ne bloque jamais. Siassumeéchouait (inatteignable : monitor root plein) →ForwardRefused, aucun socket ouvert. Le refus reste uniforme — politique et connect échoué indistinguables. Ce que cela achète, et qui ne s’observe nulle part ailleurs : (a)SO_PEERCREDdu service cible voit l’utilisateur, jamais0— un socket qui n’autorise que son propriétaire reste protégé comme il l’était sans redirection ; (b) la traversée FS est celle du compte (fsuidaligné sur l’euid, groupes supplémentaires du compte portés — cf. fast-follow ci-dessous —, ensemble effective des capabilities vidé par le noyau dès que l’euidquitte0) → aucune escalade : c’est l’invariant « avec son propre uid » du pilote monolithique, préservé en privsep. Preuves : codec + fuzz ; en in-process root, un socket joint dont le pair est l’uid emprunté et un socket dans un répertoire0700de root refusé (avec le contre-témoin : root, lui, y arrive) ; en e2e root (tests/streamlocal_forward_root.rs), un service d’écho dans un processus séparé qui rendSO_PEERCREDpar son code de sortie, plus la sonde de restauration (une redirection TCP servie après la fenêtre). Contrôle négatif exécuté : en retirant l’abaissement, les deux tests de sécurité échouent — ils discriminent bien. Zéro additif couche 0/1.- V2.6-3 streamlocal-R —
ssh -Rvers un SOCKET UNIX, CRÉÉ SOUS L’IDENTITÉ DE L’UTILISATEUR (ce jalon, la DERNIÈRE brique de V2.6). Unstreamlocal-forward@openssh.comn’est plus refusé en privsep : le monitor crée et écoute le socket demandé, pousse chaque connexion acceptée, et le worker ouvre vers le client unforwarded-streamlocal@openssh.com— servi par le même pilotePump, dans la même table de canaux, avec la même contre-pression que le-RTCP. Nouveaux RPCForwardListenUnix/ForwardCancelUnix(tags requête9/10) et pousséeForwardAcceptedUnix(tag réponse13— une étiquette à elle, parce qu’elle décide du canal SSH à ouvrir ; pas d’origine, une connexion sur socket Unix n’a pas d’adresse de pair). Chemins bornés parsun_pathdes deux côtés du fil, fuzzés. Le cœur est côté monitor, et c’est la seule opération de tout le forwarding qui écrit quelque part : la politique d’abord (AllowTcpForwarding(-R)+ permission de la clé — la même porte quetcpip-forward; puisPermitListenen fail-closed dès qu’elle borne, mot pour mot la voie monolithique), puis, dans une portée étroite sousAirTemporaryIdentity::assume(uid, gid):socket(AF_UNIX),bindlittéral,chmod 0600, non bloquant,listen— et rien d’autre. Les quatre invariants du pilote monolithique sont préservés mot pour mot : (1) le socket est possédé par le compte et ne peut naître que là où le compte pouvait déjà écrire (aucune escalade, aucun socketroot:root) ; (2) jamais d’unlinkpréalable — c’est lebindqui garantit l’absence d’écrasement (EADDRINUSE), puisqu’aucun code ne supprime un chemin que nous n’avons pas créé ; (3) le mode est posé entre lebindet lelisten(un socket qui n’écoute pas encore refuse toute connexion : pas de fenêtre exploitable), par unchmodqui ne suit pas le lien final (ADR-144) et dont l’échec défait lebind; (4) le chemin lié est tenu par un jeton RAII qui le supprime — sous la même identité — à l’annulation, au fauchage de fin de session, à la destruction du médiateur et sur tout échec après lebind: aucune sortie ne laisse de socket orphelin, et seul le chemin exact créé est supprimé. Supprimer en root ferait du monitor une primitive d’effacement arbitraire ; sous l’identité du compte, le pire cas est que l’utilisateur efface ce qu’il pouvait déjà effacer. L’accept, lui, se fait en root — il ne consulte aucun droit FS, le contrôle ayant eu lieu auconnectdu tiers contre un socket déjà user-owned — avec la contre-pressionPOLLOUTet leListenerIdstable du-RTCP : la boucle root ne bloque jamais. Preuves : codec + fuzz ; in-process root (socket crééuid=compte, mode0600, type socket ; chemin occupé refusé et fichier d’autrui intact ; répertoire0700de root refusé avec contre-témoin — root, lui, y arrive ; annulation, fin de session et destruction du médiateur qui suppriment ; poussée nommant le chemin écouté) ; démux (tunnel Unix inverse bidirectionnel, refus locaux sans franchir la frontière de privilège) ; e2e root (tests/streamlocal_remote_forward_root.rs) où un tiers sous l’identité du compte traverse le tunnel, plus la sonde de restauration (une redirection TCP servie après la fenêtre). Zéro additif couche 0/1.- Fast-follow (revue #596) — PARITÉ DES GROUPES SUPPLÉMENTAIRES en streamlocal.
AirTemporaryIdentity::assumevide les groupes secondaires ; la fenêtre streamlocal tournait donc avec le seulgidprimaire, et refusait un socket (ou un répertoire, sens-R) que l’utilisateur atteint par un groupe secondaire — alors que le démon monolithique, qui tourne avec le jeu de groupes complet du compte (initgroups), l’accepte. Fail-closed, donc aucune escalade, mais deux politiques d’accès pour la même configuration selon la topologie du démon. L’identité temporaire du monitor porte désormais les groupes du compte : nouvelle varianteAirTemporaryIdentity::assume_with_supplementary_groups(couche 1, additif, restauration fidèle des groupes précédents auDrop) etForwardUserIdentityqui transporte les groupes (bornés, inlinés,gidprimaire et groupe0écartés), alimentés par la même résolutioninitgroupsque le largage de privilèges du worker. Les autres appelants d’assume(lecture d’authorized_keys) sont inchangés — groupes toujours vidés. Le forwarding TCP n’est pas concerné (aucun DAC de groupe sur un port). Preuves : in-process root et e2e root, aux deux sens, sur un montage où le groupe est la seule porte (répertoire/socketroot:<groupe>, bits « autres » nuls) — avec le groupe ça passe, sans le groupe c’est refusé, avec un autre groupe c’est refusé. (Additif couche 1 accumulé, re-sceau à la complétion.)- V2.6 est CLOSE : forwarding privsep complet —
-L/-RTCP,-L/-Rsocket Unix, écoutes multiples, coexistence session/SFTP. Couche 2 quasi pure : une seule retouche couche 1 — la cardinalité du tampon de préservation deair_process::close_inherited_fds_except(16 → 48), qui n’est pas une propriété de sécurité (ce qui protège est ce que l’appelant nomme) mais dimensionnait un tri sur la pile trop petit pour « tuyau clair + médiation + 32 cibles ». Aucun descellement, aucun tag.✅ FAIT — ADR-117 (vague 2 #5) : REDIRECTIONS
-L/-R, TCP et socket Unix — ensemble[A]complet. Le clientair-sshgagne son premier moteur multiplexé (canaux concurrents sur un transport partagé, calqué sur le moteur serveurtcpip_forward), et l’ensemble des redirections retenues en[A]:
-LTCP (redirection locale, #556) : écoute locale → canaldirect-tcpip. Bind loopback-only revérifié au bind. Mode-N(forwarding-only).-RTCP (redirection distante, #557) :tcpip-forward+forwarded-tcpipentrant. Anti-SSRF : un canal entrant n’est accepté que s’il matche un-Rdemandé+confirmé, et la connexion part uniquement vers la cible locale de la spec (jamais une adresse du serveur).- Durcissements (#558) : plafonds de canaux (anti-fuite fd),
maximum_packet_sizeappliqué en réception, unicité(bind, port)des-R. Recrédit de fenêtre serveur (#561) : le pilotedirect_tcpiprecrédite désormais (Outbox/writer partagé) — débloque les transferts-L> 2 Mio vers un serveur Air.-Lsocket Unix (direct-streamlocal, #563) +-Rsocket Unix (streamlocal-forward, #566) : le serveur ne connecte/ne crée qu’un socket Unix au chemin validé (absolu, ≤sun_path, refus des chemins existants = pas de clobbering), politique de forwarding d’abord. Le pump est factorisé (forward_pump, partagé TCP/Unix).- Durcissement anti-TOCTOU (#568, ADR-144, descellement couche 0) : ajout du syscall
fchmodat2(honoreAT_SYMLINK_NOFOLLOW, que lefchmodat3-args ignore) → les perms des sockets streamlocal sont posées sans suivre un symlink ; une falsification locale est détectée (échecEOPNOTSUPP) et refusée. (Tag de re-sceau couche-0-v1.16/couche-1-v3.9 réservé à la certification BDFL.)Chaque incrément revu en adverse (anti-SSRF, robustesse au pair hostile, aucune panique, isolation par-canal, pas d’accès fichier arbitraire), fuzzé (proto), testé e2e dont interop avec un vrai
sshd/ssh-addOpenSSH. Différés actés ([ADR-117] §4) :-D(SOCKS),ControlMaster/ControlPath.✅ FAIT — Finding sécurité (1re passe d’intrusion, dudect) : ÉNUMÉRATION DE COMPTES PAR TIMING — CORRIGÉE. Le harnais
dudect_authorization(ADR-105, niveau-1 local) a révélé queis_authorizedfuyait l’existence d’un compte par le temps de réponse (|t| = 295, seuil 10) → énumération possible. Pré-existant (pas introduit par air-agent). Corrigé en trois temps : comparaison constant-time (#554,air_crypto::ct_eq_bytes/subtle, sans court-circuit ;|t| 295 → 0.18) ; lecture factice sur compte inexistant (#555, un magasin leurre root-owned lu et jeté pour égaliser le travail I/O — revue de sécurité adverse OUI) ; chmod anti-TOCTOU (#568, ci-dessus). Résidu niveau-2 (timing réseau) documenté.✅ FAIT — ADR-116 §3-c inc.c-4 (2026-08-04) : L’INTEROP RÉELLE BIDIRECTIONNELLE — et le défaut de fil qu’elle a trouvé. ADR-116 passe à
fait. Les deux sens de l’agent forwarding sont prouvés contre le vrai OpenSSH, par des tests e2e — et l’interop a révélé un défaut qu’aucune des trois suites précédentes ne pouvait voir.
- Le défaut : un vrai
ssh -Aenvoieauth-agent-req@openssh.comavant sonexec, donc pendant que le worker privsep est encore dans la couche connexion (ServiceMode::Deferred) et non dans le démultiplexeur. Cette boucle refusait la requête pour tout le monde ⇒ssh -Aversair-sshdn’obtenait aucunSSH_AUTH_SOCK. c-2 éprouvait la médiation, c-3 un pair scripté, les unitaires la fonction de décision (correcte) : personne ne faisait passer la requête par le trajet réel d’un vrai client.- Le correctif, au plus près : la couche connexion retient la demande et
ConnectionOutcome::Servicela porte au worker, qui la présente au monitor avant de faire lancer la session (l’ordre qu’exige unSSH_AUTH_SOCKd’environnement) ; le démultiplexeur en hérite (agent_forward_granted). Aucune sémantique de sécurité ne change — le double opt-in reste intégralement opposable, et retenir n’accorde rien. Unwant_replyen mode différé reste refusé (répondre « oui » sur une promesse serait mentir ; ni OpenSSH niair-sshn’empruntent ce chemin).- Direction A —
ssh -Ad’OpenSSH →air-sshd(tests/agent_forward_interop_root.rs, privsep réel) : lessh-add -lqui tourne sous le compte de session liste l’empreinte d’une clé que seul l’agent du client détient. Plus les deux refus (sans-A; sousAllowAgentForwarding no) vus depuis un vrai client. Skip propre sans root / sans_air-sshd/ sans OpenSSH — donc sauté en CI, exécuté à la main.- Direction B —
air-ssh -A→sshdd’OpenSSH (tests/agent_forward_openssh_server.rs) : sans root, tourne en CI. C’est OpenSSH qui honore la requête, ouvre le canalauth-agentet laissessh-add -lparler à notre agent à travers notre relais.- Aucun assert vacant : chaque cas exige un marqueur imprimé par la session distante — sans lui, une absence d’empreinte prouverait aussi bien « le forwarding est fermé » que « la session n’a jamais tourné ». Chien de garde dur partout, processus et répertoires retirés par
Drop.✅ FAIT — ADR-116 §3-c inc.c-3 (2026-08-03) : L’AGENT FORWARDING CLIENT.
air-ssh -A/--forward-agent(désactivé par défaut) demande le forwarding (auth-agent-req@openssh.com,want_reply = false— parité OpenSSH) et sert les canauxauth-agent@openssh.comque le serveur ouvre, en les proxyfiant vers l’agent ambiant du poste ($SSH_AUTH_SOCK).
- Fermé deux fois par défaut : sans
-A, aucune requête n’est émise et tout canalauth-agententrant est refusé (OPEN_ADMINISTRATIVELY_PROHIBITED) — la seconde moitié tient même si la politique serveur est contournée.- Relais opaque, sans clé : aucun octet du protocole ssh-agent n’est parsé ni réécrit (bornes : fenêtre RFC 4254 §5.2,
MAX_PACKET, 8 canaux concurrents, identifiants réservés dans1..=8— jamais celui de la session). Aucune clé privée n’entre dansair-ssh.- Le serveur ne choisit aucun chemin local : charge d’un
auth-agentvide exigée (Principe 11 — une traîne refuse l’ouverture) ; la cible est fixée à l’invocation. Dégradation propre (avertissement, connexion poursuivie sans forwarding) si l’agent manque, si$SSH_AUTH_SOCKest inexploitable, avec-N, ou avec--air-agent— dont le socket parle le protocole natif d’Air : c’est le pont OpenSSH (inc.3b) qu’il faut exporter pour le relayer.- Le moteur de session client devient multiplexé (
client.rs) : lecteur, écrivain sérialisé (l’Outboxdu moteur-N, partagée), entrée locale, pompes d’agent intégrées à chaud.client_exec/client_shell= deux modes d’un seul moteur.- Prouvé : unitaires du relais (7 refus/bornes), décision CLI, frontière
-N(le moteur de forwarding refuse unauth-agent), et e2e in-process (tests/agent_forward_client.rs, chien de garde dur) : opt-out, opt-in avec aller-retour ssh-agent octet pour octet, agent injoignable.- c-4 (interop réel bidirectionnel) est livré — cf. l’entrée ci-dessus.
✅ FAIT — ADR-116 §3-c inc.c-2 (2026-08-03) : L’AGENT FORWARDING SERVEUR.
auth-agent-req@openssh.com, jusqu’ici décodé puis refusé, est servi : le monitor crée leSSH_AUTH_SOCKde la session, l’exporte dans son environnement de login, et le worker ouvre un canalauth-agent@openssh.comvers le client à chaque connexion entrante. Conception ratifiée :notes/agent-forwarding-conception-fr.md.
- Double opt-in, fail-closed :
AllowAgentForwarding(nouveau champ, capnp@22, défautno, base-only — aucune surchargeMatch) etpermit-agent-forwardingsur la clé (ADR-128, déjà parsé). Le&&vit dans un type (AgentForwardingPolicy::permits), pas au point de décision — l’accroche// SECURITY-MANAGER:(ADR-089, différée) s’y branchera en troisième verrou.- Le chemin du socket est choisi par le SERVEUR, et le protocole le rend inexprimable autrement : la RPC monitor gagne
FORWARD_LISTEN_AGENT/FORWARD_ACCEPTED_AGENT, tous deux sans aucune charge. Il n’existe donc aucun champ où un client pourrait faire écrire un octet qui atteigne unbind(2). Le monitor tire/run/user/<uid>/air-agent-<32 hexa>.sock(getrandom) et ne le remonte pas au worker — la charge d’unauth-agentétant vide, il n’en a aucun usage.- Isolement privsep inchangé : le worker confiné ne
bind/listen/accepttoujours pas, et aucun syscall n’a été ajouté à sa cage. Le socket naît dans la fenêtreAirTemporaryIdentitydu monitor — mot pour mot celle du-Rstreamlocal — donc user-owned en0600, avec nettoyage RAII./run/user/<uid>absent ⇒ refus, et le serveur ne le fabrique pas ;/tmpn’est jamais un repli (décision BDFL).- Prouvé sous root (
tests/agent_forward_root.rs) : socket0600possédé par le compte, tunnel bidirectionnel par un tiers local sous l’identité du compte, socket disparu en fin de session — et les deux refus prouvés avec le répertoire présent, donc là où l’utilisateur écrirait sans peine.- c-3 (client) et c-4 (interop réel bidirectionnel) sont livrés — cf. les entrées ci-dessus. La part c d’ADR-116 est close.
✅ FAIT — ADR-116 (vague 2 #4) inc.3 : INTEROP PROTOCOLE
ssh-agentOpenSSH — BIDIRECTIONNELLE, prouvée contre OpenSSH réel. La partie (b) d’ADR-116 : parler le protocolessh-agentd’OpenSSH (SSH_AUTH_SOCK), dans les deux sens. Trois incréments :
- inc.3a — le codec (#550) :
air_agent::ssh_agent_proto, sans-IO (ADR-091). MessagesREQUEST_IDENTITIES/SIGN_REQUEST↔FAILURE/SUCCESS/IDENTITIES_ANSWER/SIGN_RESPONSE; trame = préfixeuint32de longueur inclus (protocole de flux, ≠ le SEQPACKET natif) ; bornes (MAX_MESSAGE_LEN256 KiB,MAX_FIELD_LEN64 KiB,MAX_IDENTITIES256) vérifiées avant allocation ; type inconnu → erreur (fail-closed). Fuzz 2,35 M runs / 0 crash + invariant de canonicité (une trame acceptée est byte-pour-byte celle que l’encodeur aurait produite).- inc.3b — le pont serveur (#551) : exposer les clés d’
air-agentà unssh(1)/gitclassique. Cœur puranswer_openssh_request(request, keyring)+ pilote minceAirOpenSshBridge(socket STREAM,SO_PEERCREDowner-only fail-closed,Dropretire le socket). Preuve interop RÉELLE :ssh-add -l/-Ld’OpenSSH_10.2 liste la clé viaSSH_AUTH_SOCK(empreinteSHA256:+ blob-Lbyte-pour-byte). Additif couche 1 :AirUnixStream::peer_credentials(SO_PEERCRED sur flux) — accumulé, PAS de re-sceau (le sceau couche-1 en vigueur reste posé jusqu’à la fin d’air-sshd).- inc.3c — le consommateur client (#552) :
air-sshs’authentifie via unssh-agentexterne.SshAgentClientSigner(implClientPublickeySigner) + flag--ssh-agent($SSH_AUTH_SOCK) /--ssh-agent-key <base64>, exclusif de-i/--key-seed/--air-agent.take_framefactorisé dansssh_agent_proto(une seule borne, partagée pont↔client). Preuve interop RÉELLE :air_ssh_authenticates_with_a_key_held_by_the_real_openssh_agent—ssh-keygen→ssh-agent -a→ssh-add→air-sshs’authentifie contre un serveur Air ; la clé privée n’entre jamais dans le processus de test ; agent arrêté parssh-agent -ksur le PID annoncé (aucunpkill).select_identityfail-closed (blob exact, jamais de repli ; 0/≥2 sans sélection → erreur).Bilan air-agent : (a) agent natif + intégration
air-sshet (b) interopssh-agentOpenSSH bidirectionnelle sont livrés et prouvés contre l’outillage OpenSSH réel, des deux côtés. Oracle-safe partout (on sert des signatures, jamais de clé privée ; peer-credSO_PEERCREDfail-closed sur chaque socket). Reste (c) le forwarding d’agent, volontairement différé (opt-in, ADR-116 §3). Suivi inc.2d (mémoriser « cet hôte utilise l’agent » côté config client) toujours ouvert.✅ FAIT — ADR-116 (vague 2 #4) inc.2 :
air-sshS’AUTHENTIFIE PAR L’AGENT — la fonctionnalité devient utilisable. Trois incréments, du plus abstrait au plus concret, chacun ne changeant rien à ce qui part sur le fil :
- inc.2a — la couture : trait
ClientPublickeySigner(air-ssh-proto) +LocalClientPublickeySigner(clé en mémoire).authenticate_clientne reçoit plus une clé privée mais un signataire. Behavior-preserving par construction.- inc.2b — le signataire agent :
air_sshd::AgentClientPublickeySigner— liste les clés de l’agent, en sélectionne une (jamais implicitement quand c’est ambigu : plusieurs clés sans empreinte désignée ⇒ erreur, pas un tirage silencieux), délègue la signature. Preuve d’équivalence byte-exacte avec le chemin local (Ed25519 est déterministe, RFC 8032 §5.1.6) : l’agent n’est pas un second chemin d’authentification à auditer, c’est le même, clé ailleurs.- inc.2c — le câblage CLI :
air-ssh connect --air-agent[=<socket>] [--agent-key <SHA256:…>]. Le socket par défaut suit la même règle que la CLIair-agent($XDG_RUNTIME_DIR, repli/run/user/<uid>) ; le socket détaché n’est accepté qu’absolu, pour qu’un[SOCKET]optionnel n’avale jamais la cibleuser@host.--air-agentest exclusif de-i/--key-seed(deux sources d’identité, c’est une ambiguïté sur qui signe — on refuse plutôt que de choisir l’identité de l’humain à sa place). La source d’identité devient un type somme explicite (ClientIdentity), le signataire actif un enum de dispatch (ActiveClientSigner) : un seul chemin d’authentification, quelle que soit la provenance de la signature — et la graine ne devient une clé en mémoire que dans la branche locale. Preuve qui compte (inc.2c) :client_authenticates_against_air_server_through_the_agent— authpublickeyréelle contre un serveur Air (socketpair, tout en process sauf le réseau), signée par un agent en processus qui détient la seule copie de la clé, contre la même clé publique autorisée que les tests locaux. L’équivalence d’octets (2b) ne prouvait pas qu’un serveur accepte ; celle-ci le prouve. Couche 2 pure : aucun descellement, aucun tag. ⏳ Suivi inc.2d — mémoriser « cet hôte utilise l’agent » dans la config client : volontairement non fait ici. Ce n’est pas une ligne : le format est un artefact Cap’n Proto versionné (ssh_config.capnp) ⇒ regénération du schéma,HostBlock/SshClientSettings,parse/write, export/import JSON, verbesconfig set, fuzz et tests de round-trip. À traiter comme un incrément à part entière, pas en marge d’un câblage CLI.✅ FAIT — ADR-116 (vague 2 #4) inc.1 :
air-agent, le SERVICE agent SSH keystore-backed — modèlessh-add,SO_PEERCRED, protocole natif fuzzé. Nouveau crate couche 2air-agent(binaire + lib,no_std+alloc, zéro dépendance externe horszeroizedéjà au workspace), qui détient les clés utilisateur déchiffrées en mémoire et signe à la demande — le pendant utilisateur d’air-keysign, même motif oracle-safe. Décision BDFL : les clés entrent À LA DEMANDE (air-agent add <fichier>), pas au démarrage : un agent fraîchement lancé ne détient rien.
- Protocole natif sans-IO (
proto, motif [ADR-091]) sur UnixSOCK_SEQPACKET(un datagramme = un message, aucun framing applicatif) : 5 requêtes (AddKey/RemoveKey/RemoveAll/ListKeys/Sign), 5 réponses (Added/Removed/Keys/Signature/Failure) ; bornes explicitesMAX_FIELD_LEN=16 KiB,MAX_MESSAGE_LEN=256 KiB,MAX_KEYS=256 ; refus muet (code figé, aucun détail) et fail-closed (un code d’échec inconnu est rejeté, jamais replié sur un défaut). Aucune réponse ne porte de matériel privé : le seul secret du fil est l’ENTRÉE d’AddKey. Fuzzfuzz_air_agent_proto— 12,1 M runs, 0 crash.- Oracle-safe :
SO_PEERCRED. À chaque connexion, l’agent demande au noyau qui est le pair et refuse en silence quiconque n’est pas son propriétaire (uid réel, égalité stricte — pas d’exception root). Le socket0600dans$XDG_RUNTIME_DIR(repli/run/user/<uid>) est de la défense en profondeur ; c’est ce contrôle-ci qui fait foi. Éprouvé sans second compte : un agent lié à un uid qui n’est pas le nôtre nous refuse — mêmeif, mêmes credentials noyau.- Zeroize ([ADR-034]) : clés sous
AirSshKeyPair(ZeroizeOnDrop) — retrait et arrêt effacent ; le tampon de réception, qui voit passer la clé privée d’AddKey, est effacé après chaque message ; leDropdu service retire le socket même sur chemin d’erreur. Un client hostile ne tue pas l’agent : une connexion fautive est abandonnée, les clés restent.- CLI
air-agent daemon|add|list|remove|remove-all(parsing manuel,runrend unCliResult⇒ testable en process contre un vrai agent).listne montre qu’empreintes, clés publiques et commentaires. Packaging : paquetair-agent(depends: air-keystore) — le regroupement clientair-ssh+air-agenten un seul .deb (directive BDFL 2026-07-27) reste un refactor séparé, hors inc.1.- Additif couche 1 (minimal, requis par l’ADR) :
AirUnixSeqpacket::peer_credentials(SO_PEERCRED) dansair-socket— la couche 1 ne l’exposait pas, et le contrôle d’appelant d’ADR-116 §1 est inatteignable sans lui (la couche 0 est hors de portée d’une crate couche 2). Deux tests dédiés ; le module l’annonçait déjà comme additif « au besoin, typé ». Specdocs/specs/layer-1/air-socket.mdmise à jour.- HORS PÉRIMÈTRE inc.1 (inchangé) : intégration
air-ssh= inc.2 ; codec du protocole ssh-agent /SSH_AUTH_SOCK= inc.2b ; agent forwarding = inc.3 ; unité systemd user et mode JSON = différés.✅ FAIT — ADR-135 (descellement ADDITIF
couche-1-v3.5: le duplicateur fermait, lui aussi, le descripteur neuf). Corrige le défaut ci-dessus, jumeau d’ADR-131 §3 dont il achève la correction : le registreair-handlea deux portes d’entrée (registeretduplicate), et ADR-131 n’en avait corrigé qu’une. Correctif d’une ligne —if let Some(stale) = table.insert(...) { core::mem::forget(stale); }— posé sur l’insertion commune aux deux voies deduplicate(la voiedup3évinçait déjà sa victime en amont, ADR-069), ce qui ferme la question pour toute voie future. Aucun symbole ajouté, aucun retrait, aucune signature modifiée : c’est une correction du comportement d’une primitive scellée (une fermeture qui avait lieu n’a plus lieu), remontée à ce titre au superviseur. Preuves, les deux vérifiées ROUGES sans le correctif — ① unitaire,duplicating_onto_a_stale_number_does_not_close_the_fresh_fd(air-handle) : une entrée périmée est fabriquée pour un numéro fermé hors du registre,duplicatesans cible le réattribue, et le descripteur rendu doit survivre —EBADFsans leforget; ② bout-en-bout root sur le vrai binaire,migrate_privsep_root: l’assertion de sortie de la commande distante, laissée eneprintln!par ADR-134 en attente du correctif, est désormais active — sans le correctif,stdoutdu client est vide (mesuré) ; avec, la commande rend son marqueur sous l’uiddu compte de session (donc non-root). C’est le dernier blocage fonctionnel du déploiement 0.3.0. ⏳ Tag de re-sceaucouche-1-v3.5à poser par le superviseur (pas par cet ADR).✅ FAIT —
air-account: prompt de mot de passe + écho coupé (défaut UX/sécurité signalé par le BDFL sur machine réelle, 2026-07-29). Le CLI lisait le mot de passe administrateur (ADR-101) sur stdin sans imprimer de prompt : en interactif la commande semblait se figer ; et stdin restant en mode canonique avecECHO, le mot de passe s’affichait à la frappe. Deux défauts, un seul correctif. Le lecteur injecté devientFn(&str) -> Result<Vec<u8>, String>(le&str= libellé du prompt), donc contextuel — indispensable puisquepasswdlit deux mots de passe : « Mot de passe administrateur : » puis « Nouveau mot de passe pour ‘’ : ». Nouveau module air-account-cli/src/password.rs(read_password_from), deux régimes : ① stdin est un terminal ⇒ libellé sur stderr (jamais stdout, qui porte--json/list/showet peut être redirigé) +ECHOretiré des drapeaux locaux viaair-terminal(couche 1, ADR-060 —tcgetattr/tcsetattr, arête 2→1) et restauré parDropy compris sur erreur ou panique,TCSAFLUSHpurgeant l’entrée pré-frappée (sémantiquegetpass(3)) ; ② stdin non-tty (pipe, script, test) ⇒ comportement d’origine strictement inchangé : une ligne lue, aucun prompt, aucunioctl. Preuves : le régime terminal est exercé en process sur un PTY réel (air_terminal::openpty) — une source de lecture espionne interrogetcgetattrau moment oùread_untillui demande des octets, prouvant sans course que l’écho était bien coupé pendant la saisie, puis restauré après ; non-régression du piping prouvée (puits d’écriture vide) ; libellés vérifiés côtécli.rs(deux saisies : admin puis nouveau) ; et le vrai binaire validé sous PTY (deux prompts visibles, aucun des deux secrets ré-affiché, stderr vide en piping). Couche 2 pure : aucun descellement, aucun ADR, aucun tag.main.rsreste la plomberie exclue de couverture (câblage fd 0 / stderr) ; toute la logique est danspassword.rs, mesuré.✅ FAIT —
air-sshd: le monitor journalise la RAISON d’un refus d’autorisation (diagnostic StrictModes, défaut signalé par le BDFL en production 2026-07-29). Sous privsep, le monitor calculait déjà une raison précise (AuthorizeRefusal) à chaque refus, et la jetait : le fil recevait unAuthorizeDenymuet (correct, anti-énumération RFC 4252/U.4) et le journal serveur ne montrait qu’« échec d’authentification ». Diagnostiquer unStore(LaxPermissions)— répertoire parent d’authorized_keysinscriptible par le groupe, écarté comme le fait OpenSSH — a coûté ~10 étapes, alors qu’OpenSSH écrit « Authentication refused: bad ownership or modes for directory … ». Correctif en trois pièces : ①Displayactionnable par variante —StoreRefusalen a désormais un (7 variantes : cause + geste,chmod 700/chown…) etAuthorizeRefusall’embarque (« magasinauthorized_keysécarté — … ») au lieu d’imprimer leDebugdu code ; ②MonitorAuthorizer::authorize_with_reason— l’unique porte par laquelle la raison sort de la décision, sans toucher la réponse du fil (authorizey délègue, comptage inchangé) ; ③SessionPolicy::authorize(le seul étage qui détient et la raison et le puits de log) émetAirOperationalLog::auth_refused— nouvelle méthode du trait ADR-111 d — avant l’auth_failedexistant, à la manière d’OpenSSH. La raison est dans leMESSAGE(autorisation refusée pour <user> depuis <ip> : <raison>,journalctln’affiche que lui) et en champAIR_REASON(filtrable). Rien ne change sur le fil :AuthorizeRequest/MonitorResponseintouchés —AuthorizeDenyn’a aucun champ où loger une raison, l’anti-énumération tient donc par le type, pas par la discipline de l’appelant. Émission best-effort (le trait ne rend rien : un journal muet n’ouvre ni ne ferme de session). Preuves, rouges sans le correctif : refus journalisé avec compte + pair pourUnknownUseret trois causes de magasin (UnsafePath,Unreachable,NotARegularFile) via le magasin système (lu en root, sans abaissement) ; test roota_store_kept_out_by_strict_modes_is_explained_in_the_journalrejouant le cas de production (magasin conforme sous un parent0770) ; anti-fuite : deux refus de causes différentes produisent le même datagramme encodé, et aucun fragment de la raison ne s’y trouve ; table des libellés par variante (les deux enums). Couche 2 pure : aucun descellement, aucun ADR, aucun tag.✅ FAIT — ADR-114 (vague 2 #2) : extraction d’
air-json, le codec JSON possédé, en crate couche 1 partagée — descellement ADDITIF ADR-136. Le codec JSON maison né dansair-sshd/src/json.rs(E.6, ADR-099) était enfermé dans un démon SSH alors que la CLIair-keystore(ADR-115, vague 2 #3) a besoin du même codec pour son mode JSON agent-safe (entrée surstdin, sortie structurée : zéro piège de quoting shell pour un outil ou un agent). Nouvelle crateair-json(couche 1,no_std+alloc, pur codec zéro I/O, zéro dépendance externe, unique arête 1→1 versair-base-core) :AirJsonValue(variantes RFC 8259,Number=i64), accesseurs, sérialiseur compact,parse(&[u8]), et la borne publiqueMAX_DEPTH. Erreurs →AirError(ADR-019, kindInvalidData, message qui localise l’octet fautif) au lieu de l’enum maisonJsonError: l’appelant couche 2, qui mêle déjà I/O et parsing dans le mêmeResult, n’a plus aucune conversion à écrire.air-sshdcesse d’héberger le module (src/json.rssupprimé) et dépend d’air-json;air-sshd/air-sshconfig export|importrecâblés, non-régression E.6 verte sans toucher aux tests (config_json.rs,config_client_json.rsinchangés). Barre couche 1 tenue : couverture 100 % lignes ET branches per-crate — obtenue en retirant les bras inatteignables de la forme d’origine (unitoamaison dont l’unwrap_or(0)ne pouvait jamais servir, unget(..n)dont leNoneétait impossible : de la prudence d’apparence, pas d’effet) au profit de formes dont chaque chemin d’erreur est atteint par un test ; fuzzfuzz_air_json_parse(renommée depuisfuzz_air_sshd_json) ; proptestparse ∘ serialize = identité+ point fixe + « octets arbitraires ⇒ toujours unResult». Périmètre INCHANGÉ (entiers, pas de flottants — Principe 5 ; BMP ; zéros de tête et clés dupliquées tolérés et documentés, les durcir serait un changement de comportement). Pas deserde_jsonen production (ADR-024/088/073) ;serdereste permis dev/test-only (ADR-030) et n’est tiré nulle part. ⏳ Tag de re-sceaucouche-1-v3.6à poser par le superviseur (pas par cet ADR). ⚠️ Remonté, non embarqué :air-account-cligarde son propre module JSON — un écrivain d’octets non-UTF-8 (noms de compte, chemins), contrat qu’air-jsonne sait pas rendre aujourd’hui (AirJsonValue::Stringporte unStringUTF-8). Une consolidation exigerait une extension de périmètre d’air-json, donc une décision — à instruire quand la CLIair-keystoreaura montré le besoin réel.
📦 Packaging (directive BDFL 2026-07-27) :
air-agentsera livré avecair-sshdans un seul .deb versionné (client) ;air-sshd= son propre .deb indépendant (serveur).cargo xtask debdevra produire deux paquets, jamais fusionnés.
✅ CHANTIER CLOS — COMPLÉTION DU PAL : plus aucun
unsupported(DIRECTIVE BDFL 2026-07-25). Clos par la migrationstdlinux-air (tagmigration-std-linux-air). Tout ce qui restait en repliunsupporteddans lastdlinux-air a été supporté/implémenté. Origine : question BDFL surgetcwd/chdir, dont la réponse a révélé un motif — la capacité existe en couche 1 et dans la libc C-ABI, seul le binding versstdmanque. Ce n’est donc pas un manque de fonctionnalité d’Air, c’est du câblage non écrit : le PAL a été branché au fil des besoins réels (les 5 correctifs révélés par le déploiement de juillet —sys/args,sys/time… — sont le même motif), et ce que personne n’avait encore appelé n’a jamais été relié. Point de départ VÉRIFIÉ (audit du patchrt/rust-src-air/air-pal.patch) : familles déjà pourvues d’un brasairréel =alloc,args,env,fd,fs,io/error,net/connection,paths,pipe,process,random,stdio,thread,time. Repliunsupportedidentifié : danssys/paths, seulcurrent_exeest réel —chdir,getcwd,home_dir,temp_dir,split_paths,join_paths(+SplitPaths/JoinPathsError) retombent tous surunsupported.thread_local/mod.rsest touché par le patch sans brasair: à auditer. Conséquence concrète aujourd’hui :std::env::current_dir()rendUnsupportedsur*-linux-airalors queAirFileSystem::current_directory()fonctionne dans le même binaire. Impact actuel : NUL — vérifié, aucun code livré n’appelle ces fonctions (les seules occurrences destd::env::temp_dir()sont dans du code de test, qui tourne sur l’hôte gnu). Le gap est réel mais dormant ; le chantier le referme avant qu’il ne morde. Méthode : (1) audit exhaustif du patch → liste complète desunsupportedfamille par famille (ne pas se fier à la seule famillepathstrouvée ici) ; (2) pour chaque entrée, binder la couche 1 (jamais la couche 0 — règle dure, précédent #417) ; (3) un manque en couche 1 = additif couche 1 d’abord ; (4) preuve on-target par famille.temp_dir/home_dirsont triviaux (lecture deTMPDIR/HOME) ;chdir/getcwdsont un binding direct surAirFileSystem::change_directory/current_directory, qui existent déjà. ⏱ ÉLARGISSEMENT (directive BDFL 2026-07-25, même jour) — le critère de réussite devient : LA SUITE DE TESTS TOURNE SUR NOTREstd. Règle posée : aucun code Rust d’Air, tests compris, ne s’exécute contre la glibc — donc contre lastdde la toolchain. ADR-103 §2 amendé en conséquence : l’autorisation « banc de test hôte » est retirée ; la std gnu reste non-livrable, mais son usage comme harnais devient un état transitoire à résorber, plus une position tenable. Inventaire factuel : 3 417 tests unitaires + 268 tests d’intégration sur 69 crates tournent aujourd’hui sur gnu, via trois invocations CI sans--target(cargo test --workspacedanstest-coverageetaarch64-native,cargo llvm-cov --workspace) ; seul le harnaisrt/(selftest + démonstrateurshello-*) s’exécute sur notrestd, et ce sont des exécutables de preuve, pas une suite de tests. Ce que l’écart a DÉJÀ coûté : le testair-process::drop_to_user_resolves_and_drops_under_roots’interbloque sous couverture — ilclone3puis alloue dans l’enfant ; sur notre cibleair_alloc::reset_after_fork(ADR-056 D8) remet le futex de l’arène àUNLOCKEDet le cas est résolu, mais sur l’hôte gnu l’allocateur est celui de la glibc,clone3en syscall brut court-circuite ses gestionnairespthread_atfork, et rien ne réinitialise ses verrous. Le défaut n’existe que parce que le test tourne contre la glibc — il disparaît sur la cible. Portée supplémentaire : faire fonctionnerlibtestsur notrestd(std::process,std::thread,std::io, affichage, codes de sortie) et rendrecargo test --target *-unknown-linux-aircapable d’exécuter les binaires produits, puis basculer les invocations CI sur--target. Ordre suggéré : compléter lesunsupported(dontpaths) → prouverlibtestsur cible → basculer la CI famille par famille, en gardant gnu en parallèle le temps de la transition (une bascule d’un bloc perdrait la capacité de diagnostiquer).⏱ 2026-07-26 — CHANTIER COMPLÉTION PAL, INCRÉMENT 1a :
pathsCOMPLÉTÉ + correctifis_absolute, PROUVÉ ON-TARGET (x86_64, exit 0 ; aarch64 cross-compile). Premier incrément du chantier « tous les crates sur NOTRE std ». (1)sys/pathscomplet : les 6 fonctions + 2 types qui retombaient surunsupportedsont réelles —getcwd/chdirbindentair-filesystem::current_directory/change_directory(couche 1, existants),home_dir/temp_dirlisentHOME/TMPDIR,split_paths/join_pathssont de la logique pure (séparateur:).paths/mod.rsroute désormaisuse air as imp(plus aucun repli). (2) BugPath::is_absolute()trouvé et corrigé :sys/path/unix.rs::is_absolutefaisaitif cfg!(any(unix, hermit, wasi, motor))— Air (non-unix, ADR-106) tombait dans la branche « préfixe Windows » →/foonon absolu. Correctif :target_os = "air"ajouté (Air = userland Linux,/⇔ absolu). Classe de findings que la migration révèle : du code std gated surcfg(unix)se comporte différemment sur Air. (3) Preuve : cratert/crates/hello-pathsexerce les 6 fonctions
is_absolute→ exit 0 en natif sur speedy (static-pie, zéro glibc) ; cross-build aarch64 OK. Round-trip du patch (air-pal.patchrégénéré au format d’origine) vérifié. Leçon opérationnelle :build-stdcache std agressivement et ne re-détecte PAS les éditions du rust-src vendoré →cargo cleanobligatoire pour forcer sa reconstruction (à intégrer au cache d’ADR-107). Restepaths-adjacent : les 5 verrousfs(flock, couche 0 déjà présente) — différés (peu exercés).⏱ INCRÉMENT 1b —
libtestPROUVÉ SUR CIBLE.cargo test --target x86_64-unknown-linux-aircompile ET exécute le harnais de test std sur NOTRE std, nativement sur speedy (static-pie). Cratert/crates/air-libtest-probe: 3#[test](dont un exerçantis_absolute/current_dir) → 3 passed. Recette :build-std=["std","panic_abort", "test"]+panic-abort-tests = true+ dépair-std-entrygatedcfg(target_os="air")(fournit_start; libtest fournitmain) +#![feature(restricted_std)]. Arche identique ⇒ exécution directe (pas de runner). Comportementpanic=abortconfirmé :panic-abort-testspermet de bâtir le harnais mais un test qui échoue avorte tout le binaire (SIGILL, core dump au plus près — choix assumé) ; pas d’isolation par sous-processus → diagnostiquer en relançant le test/crate seul.⏱ INCRÉMENT 2 — HARNAIS
cargo xtask test-air: la migration d’un crate = UNE commande, ZÉRO édition par crate. Découvertes qui simplifient tout : (1)restricted_stdPAS requis (le patch a déjà ajoutéairà la liste « std complet » destd/build.rs). (2) Le_starts’injecte au link final seulement viaRUSTFLAGS/RUSTDOCFLAGS = -Clink-arg=<_start.o>(objet extrait d’air-std-entrycompilé pour la cible) — ça n’affecte pas la compilation des rlibs build-std (core/std intacts) et ne touche AUCUN crate (les 69 fardeaux dev-dep/feature évités).cargo xtask test-air [-p <crate>] [--clean]encapsule : vendor + build_start.o+cargo +nightly test --target linux-air -Zbuild-std=std,panic_abort,test -Zpanic-abort-tests. Prouvé :test-air -p air-random→ 3 unit + 1 doctest verts sur notre std, natif sur speedy.⏱ INCRÉMENT 3 — BACKEND
getrandomcustom pour os=air (air-getrandom-shim). En chaînanttest-air, 1er mur :proptest(dev-dep quasi universelle) →rand→getrandom0.3.4 refuseos=air(« target not supported »). Solution transverse (pas 69 corrections) :rt/crates/air-getrandom-shimfournit__getrandom_v03_custom(backend custom getrandom 0.3) via un syscallgetrandom(2)inline (asm x86_64/aarch64, aucun symbole externe) ⇒ objet auto-contenu en release, injecté au link comme_start.o.test-airle bâtit+extrait+ injecte automatiquement + pose--cfg getrandom_backend="custom". getrandom débloqué (l’erreur disparaît). air-random reste vert avec le shim câblé. ⚠️ MUR SUIVANT (décision BDFL requise) — l’écosystèmeproptestprésume POSIX. Après getrandom,proptesttirewait-timeout(et probablementrusty-fork) gatedcfg(unix)→ échec sur os=air (Air non-unix, ADR-106). Ce n’est pas notre code : c’est la pile de test tierce. Option recommandée :proptestendefault-features = false(workspace) pour larguerfork/timeout(doncwait-timeout/rusty-fork) — le property testing cœur reste ; impact : perte de l’isolation fork/timeout aussi sur gnu (mineur, cohérent avecpanic=abortsans isolation). Alternative : forker/shimmer chaque dep POSIX. À trancher avant de poursuivre le chaînage des crates qui utilisent proptest.⏱ INCRÉMENT 4 — DIAGNOSTIC CORRIGÉ + FORK proptest (la bonne direction). Un aller-retour a clarifié où est vraiment la complexité : elle n’est PAS dans Air et la toolchain de test n’impose PAS POSIX (libtest est neutre — prouvé). Le mur venait de crates tiers qui ne connaissent pas la cible Air :
proptestfait le raccourci fauxnot(windows) ⇒ unix→ tirestd::os::unix(OsStringExt::from_vec). J’avais d’abord exposéos::unix::ffidans la std d’Air — mauvais réflexe (déformer Air pour une présomption fausse d’un tiers). REVERTÉ (commitb1d4da6,cfg(unix)restefalse, std d’Air pure). Bonne approche (décision BDFL) : patcher la DEP, pas la std — cohérent avec la doctrine « Air possède/ patche ses deps » (ADR-024/030). LIVRÉ : forkproptestvendoré+patché (modèlebitflags) :rt/vendor-deps/proptest-air.patch(branche Air via l’API octets neutreOsStr::from_encoded_bytes_unchecked; masques flottants fully-qualified — proptest vs nightly récent ; features par défaut allégées : fork/timeout largués → plus de wait-timeout/rusty-fork/tempfile) +apply-proptest-air.sh(regen depuis le registry, vendor gitignoré).xtask test-airl’active uniquement via--config patch.crates-io→ la barrière gnu garde le proptest d’origine. RÉSULTAT :test-air -p air-sys-types→ 201 tests tournent sur Air, std pure, proptest patché auto-régénéré. Seul échec : le#[should_panic](limite inhérentepanic=abort— panique attendue avorte le process, pas d’unwinding pour la rattraper). DÉCISION EN ATTENTE : sort des#[should_panic]sur cible (gatercfg(not(target_os="air"))? compteur à part ? adapter ?). Puis chaînage crate par crate (chaque dep tierce inconnue d’Air = un petit patch vendoré, modèle établi).⏱ 2026-07-27 — INCRÉMENT 5 :
air-sys-syscallVERT SUR NOTREstd(le crate le plus dur, couche 0) + un VRAI DÉFAUT DE TEST révélé par la migration. Résultat :test-air -p air-sys-syscall→ 491 passed / 16 ignored / 0 failed (unit) + 78 (intégration) + 4 doctests, exit 0. Barrière gnu intacte (815 + 78 + 4,clippypropre). La couche 0 tourne donc entièrement sur lastdd’Air (air-sys-types201,air-sys-syscall573).(a) FINDING — le test
clock_nanosleep_relative_is_interruptible_by_signalne testait rien sur gnu. Symptôme sur cible :SIGSEGV(si_code = SI_KERNEL,si_addr = 0) — la signature exacte d’unforce_sigsegvdu noyau quand il n’arrive pas à monter la trame de signal. Cause : le helper de testTestSignalHandlerrefaisait unrt_sigactionbrut à la main avecsa_flags = 0, donc sansSA_RESTORER— obligatoire sur x86_64 quand aucune libc ne fournit le trampoline de retour de handler. Pourquoi gnu ne le voyait pas : lastdde la toolchain installe son propre handlerSIGSEGV(détection de débordement de pile) qui absorbe la faute synthétique et laisse le programme continuer ; le test passait alors que le handler de test ne s’exécutait jamais (mesuré sur gnu par un probe dédié : 0 invocation sansSA_RESTORER, 1 avec). Sur Air,sys/stack_overflowest un stub assumé : rien n’absorbe, le défaut devient visible. Correctif (pas un gate) : le helper est rebâti sur l’API de production du crate même —signal::async_handler::install/restore, qui posent déjàSA_RESTORER+ trampolinert_sigreturnsur x86_64 et passent par le VDSO sur aarch64. Le test vérifie désormais réellement ce qu’il annonce, sur les deux cibles, et ~140 lignes dert_sigactionasm dupliqué disparaissent du module de test. C’est le dividende attendu du chantier : lastdd’Air, plus mince, ne masque pas les défauts que la glibc + la std gnu masquaient (2e cas aprèsPath::is_absolute).(b) PIÈGE D’OUTILLAGE CORRIGÉ — vendor
rust-srcpérimé, silencieusement.xtask test-airne régénérait le rust-src patché que s’il était absent : un vendor antérieur à l’incrément 1a faisait tourner toute la suite contre unestdobsolète, oùsys/pathsretombait encore surunsupported(untemp_dir()de test paniquait « no filesystem on this platform » — diagnostic trompeur, il accuse lastd, pas le crate).ensure_vendorcompare maintenant la date d’air-pal.patchà celle du vendor, régénère et purge letarget/d’office si le patch est plus récent.(c) Politique appliquée sur les 16
ignored:#[should_panic]/catch_unwind/ thread qui panique /abort()en enfant → gatés#[cfg(not(target_os = "air"))]avec motif explicite (panic=abort: panique non rattrapable). Oracles POSIX (UnixStream/UnixListener,OpenOptionsExt::custom_flags) → gatés de même. Octets :.as_bytes()surOsStr→.as_encoded_bytes()(API stable os-neutre, aucuncfg). La barrière gnu n’est jamais touchée : tout gate est inerte hors cible.⏱ 2026-07-27 — INCRÉMENT 6 : COUCHE 1 ENTIÈRE (28 crates) SUR NOTRE
std. Balayage systématiquetest-aircrate par crate. 20 des 28 crates étaient verts d’emblée —air-alloc,air-env,air-handle,air-libm,air-memory,air-random,air-poll,air-shm,air-signal,air-socket,air-stdio,air-system,air-terminal,air-runtime,air-crypto,air-config-compile,air-config-schema,air-account,air-account-schema,air-base-lib: zéro édition. Ce chiffre est le vrai résultat de l’incrément : le PAL d’Air est assez complet pour que la couche 1 n’ait, en très large majorité, rien de spécifique à faire. Les 8 restants et leur traitement :
crate accroc traitement air-base-coreSIGSEGVsursleep_until_retries_after_eintrmême défaut SA_RESTORERqu’en couche 0, copié-collé → rebâti surasync_handlerair-threadAirThreadBuilderconfigure outsur cible ; 3 tests de non-poisoningharnais coexistence.rsgaté sous lecfgde l’API qu’il exerce ; tests à panique délibérée gatés (panic=abort)air-uringTcpListener: IntoRawFdabsenthelper reborné sur AsRawFd+forget— os-neutre, le PAL n’expose que les impls emprunteuses (différé assumé, ADR-088)air-processPermissionsExt::set_mode→ fchmodat(couche 0)air-filesystemsymlink/PermissionsExt/MetadataExt/UnixListener4 helpers os-neutres ( symlinkat/fchmodat/statx/mknodatFIFO) en tête du fichier de testair-deviceOsStrExt+ 4symlinkas_encoded_bytes+ helpersymlinkatair-config6 OsStrExt/PermissionsExt(dont un exemple)UTF-8 explicite + fchmodatair-std-entryduplicate symbol: _startau linkharnais : test-airn’injecte plus_start.oquand la crate testée est celle qui le définitCe que la migration a appris, au-delà des chiffres. (1) Le défaut
SA_RESTORERétait dupliqué en couche 0 et en couche 1 — deux copies du même helper de test artisanal, deux fois faux, deux fois masqués par gnu. Les deux sont maintenant bâtis sur l’API de production, qui, elle, était correcte depuis le début. (2) La plupart des « accrocs » ne sont pas des manques d’Air mais du code de test écrit en réflexe POSIX là où l’API d’Air existait déjà : on a donc récupéré la couverture au lieu de la gater, en réécrivant sur les syscalls d’Air (dogfooding). Les seuls vrais gates restants sont ceux quepanic=abortimpose (panique délibérée non rattrapable) et une API délibérément absente sur cible.⏱ 2026-07-27 — INCRÉMENT 7 : COUCHE 2 ENTIÈRE (41 crates) SUR NOTRE
std. Même méthode. 33 des 41 verts d’emblée, zéro édition (dont tous lesair-libc-*sauf trois,air-com*,air-object/-c,air-value/-c,air-service,air-notifyd,air-ssh-proto,air-sftp-proto,air-base-c/-capi,air-sshd-schema). Les 8 restants :
crate accroc traitement air-account-cli,air-libc-fileio,air-sshdoracles PermissionsExt/MetadataExt/FileTypeExt,symlinkoracles relus par la couche 1 ( AirFileManager::metadata/change_mode/symbolic_link) — os-neutre, et plus franc : l’oracle n’emprunte rien à l’implémentation testéeair-asyncUnixStream::pair,IntoRawFdair-socket::socket_pairen dev-dep (2→1 conforme) : rend directement desOwnedFd, le détour de conversion disparaît ; helper reborné surAsRawFd+forgetair-libc-capiduplicate symbol: air_mainle bridge air_main(pour un programme C) est gaténot(test): le binaire de test de la crate est un programme Rust, dont l’entrée est l’air_mainde lastdair-libc-threadpthread_create/join/detach/equalabsents ;feature(thread_local)tests gatés sous le cfgde l’API exercée (cycle de vie = hôte, le spawn Air-natif est un chantier distinct) ; lenot(test)parasite ducfg_attr(feature(thread_local))retiréair-libc-cExitStatusExt::signalseule l’assertion sur le numéro de signal est gatée ; la mort par signal reste prouvée par code().is_none()(API neutre). Harnais C hôte, sauté sur cible de toute façonair-object-macrostrybuild: « could not find specification for target »gaté hôte : trybuildrelancerustcavec le triple par son nom, or la cible d’Air est un JSON. Limite detrybuild, pas un écart d’Air — et une proc-macro est toujours compilée pour l’hôte⚠️ DÉFAUT RÉEL TROUVÉ ET CORRIGÉ — extraction d’
OwnedFdd’un type réseaustd. Le raccourci « évident » pour contourner l’absence d’IntoRawFdsur cible —as_raw_fd()+mem::forget— est unsound sur Air : le fd sort duTcpListeneren laissant son entrée orpheline au registreair-handle; quand un socket ultérieur réutilise ce numéro,air_handle::registerécrase l’entrée périmée, dont leDropferme le fd fraîchement ouvert. Symptôme observé : unbindqui rendEBADFune dizaine de tests plus loin. C’est exactement la raison pour laquelle le PAL n’expose que les implsos::fdemprunteuses pour les types réseau (les propriétaires exigeraient de désenregistrer — différé, ADR-088). Correctif : les sockets de test sont désormais créés parair-socket(couche 2) ou la couche 0 (air-uring, couche 1), dontinto_owned_fddésenregistre proprement. Sur gnu le raccourci est inoffensif : seul le passage sur notrestdpouvait révéler ce défaut — 3ᵉ cas aprèsPath::is_absoluteetSA_RESTORER.Deux points remontés (pas de décision prise ici). (1) Les impls
os::fdpropriétaires des types réseau (IntoRawFd/FromRawFd/From<_> for OwnedFd) restent différées — le contournement ci-dessus montre qu’il vaut mieux les implémenter proprement (register/ unregister) que laisser chacun improviser. (2) Le crochet de destruction TLS à la sortie de thread sur cible manque toujours (air-libc-threadle REMONTAIT déjà) : l’assertion correspondante est la seule du chantier gatée pour cause de fonctionnalité absente.Bilan du chantier — la suite entière tourne sur notre
std. Couche 0 : 2/2. Couche 1 : 28/28. Couche 2 : 41/41, plusxtask. 71 crates, soit 53 verts sans la moindre édition (75 %). Aucun descellement de couche, aucun ajout deos::unixni de compat POSIX au PAL : lastdd’Air reste pure etcfg(unix)restefalse(ADR-106). Deux forks de deps tierces seulement (proptest, backendgetrandom), tous deux confinés au--configdetest-air. Les gates posés se rangent en trois familles, toutes documentées sur place :panic=abort(panique non rattrapable), API délibérément absente sur cible (sous lecfgde l’API elle-même), et harnais hôte par construction (toolchain C,trybuild).⏱ 2026-07-26 — ADR-107 ÉCRIT (Accepté, décision BDFL) : le CADRE CI/CD de ce chantier. La discussion « écarts OpenSSH » (serveur + client) a été triée en ~11 ADR répartis en 2 vagues (V1 : air-keystore → certs+CA → KEX ML-KEM → air-log → air-sandbox ; V2 : le reste). Avant d’attaquer la vague 1, le BDFL a demandé de figer le pipeline pour que les nouveaux crates naissent conformes → ADR-107 (
docs/adrs/ADR-107-...). Décisions normatives : (1) gates sur la std linux-air, plus gnu — c’est le cadre du critère « la suite tourne sur NOTRE std » ci-dessus (l’ordre suggéré y est intégré : bascule--targetfamille par famille) ; (2) raspi-srv-2 cesse de compiler → cross-build x86 (carbon/speedy) + exécution native sur le Pi viatarget.<triple>.runner(transfert+exécution SSH d’un binaire static-pie autonome ; trivial car C-free, dividende d’ADR-088) ; (3) deux niveaux de gate — PR (crates affectés, x86, rapide, Pi non sollicité) / merge-queue (full + natif aarch64 + couverture 100 % +repro) ; (4) cachesbuild-std/sccache/target/chaud (sûrs car reproductibles, ADR-025) ; (5) jobs dédiés dudect (constant-time, 2 arches), fuzz deux étages (smoke PR / profond nightly), privilégié isolé sur runner jetable. Répond directement à la préoccupation BDFL « qu’une PR ne reconstruise pas tout, surtout pas sur le Pi ». Non implémenté (ADR = décisions) : l’incrément CI (ci.yml+ci/run-on-target.sh) reste à produire. SUMMARY + registre (v3.42) à jour,mdbook buildvert.⏱ 2026-07-25 — OUVERTURE CHANTIER
air-sshdphase 4 : SFTP v3 +authorized_keysadministrables (ADR-104, PROPOSÉ — en attente de ratification BDFL). Directive BDFL : (1) SFTP servi in-process (pas d’exécutableair-sftp-serverséparé), v3 strict (cible d’interop = clientsftpOpenSSH, depuis un Mac) ; (2)air-sshddoit résoudre les clés comme un serveur OpenSSH — magasin de l’utilisateur et magasin système — mais dans l’arbre Air (jamais$HOME/.ssh/), avec import des clés dans l’artefact binaire plutôt que recopie de fichiers en clair. Constat d’inventaire : le volet (2) est à moitié bâti — ADR-096 a figé la cascade etFileAuthorizedKeys::with_system_fileexiste et est testé, mais il manque l’écrivain (le magasin ne se peuple que par un helper de test ⇒ fonctionnalité inutilisable en pratique) et le chemin du magasin système n’est pas dans le schémasshd-config.capnp(⇒with_system_fileinatteignable depuis la config). Arbitrage central de l’ADR — le modèle de privilèges : « in-process » ne peut pas vouloir dire « dans le démon », sinon lesopen/read/unlinkse feraient sous l’identité root et il faudrait réimplémenter le contrôle d’accès Unix (classe de vulnérabilité connue). Décision proposée : worker forké sansexec— le fils largue ses privilèges (drop_privileges_to, déjà utilisé par le chemin shell) et le père pompe canal ↔socketpair(machinerie E.3 réutilisée) ⇒ un seul binaire, et c’est le noyau qui applique le contrôle d’accès. Leforksansexecest sûr ici carair-sshdest mono-réacteur mono-thread (le deadlock io_uring+fork, #128/#161, supposait un autre thread) ; invariants imposés au fils : ne jamais toucher l’anneau hérité, fermer tout fd horssocketpair. Repli documenté si S.3 les infirme : ré-exécution de soi (/proc/self/exe --sftp-worker), toujours un seul binaire. Plan : K.1→K.3 (écrivain + parseur de ligne OpenSSH fuzzé ; CLIair-sshd authorized-keys list/add/remove/importgatée ADR-101 ; champ additifsystemAuthorizedKeysPath @1+ cascade) puis S.1→S.5 (cœurair-sftp-protosans-IO fuzzé ; pilote lecture seule ; worker privsep ; écriture ; interop scellée en CI + transfert ≥ 100 Mio depuis un Mac). Aucun descellement couche 1 prévu (à re-confirmer en S.3/S.4 ; un manque = additif couche 1, jamais de raccourci couche 0). Re-sceaucouche-1-v2.4après clôture d’air-sshd(décision BDFL). ⏱ ADR-104 RATIFIÉ (Accepté, 2026-07-25) — avec deux contraintes rappelées en §7 normatif : le code nouveau ne repose que sur la couche 1 (mécanisécheck-layers; seule exception structurelleair-sys-types, crate transverse de types sans syscall, ADR-052 D6), et les exécutables produits ne dépendent JAMAIS de la libc gnu (mécanisé deux fois :check-target+ garde-foureadelf -ddecargo xtask deb, qui refuse toutNEEDED). K.1 ✅ — écrivain + import du texte OpenSSH.build_authorized_keys(entrées → capnp scellé, tri canonique algorithme puis blob + déduplication, ADR-025 : mêmes clés ⇒ mêmes octets quel que soit l’ordre d’ajout) +write_authorized_keys(swap atomique0600viawrite_atomic_with_mode, mode fixé avant lerenamedonc sans fenêtre ni dépendance à l’umask). Cœur d’import sans-IO (ADR-091) :parse_openssh_authorized_key/parse_openssh_authorized_keys— base64 standard strict (décodeur couche 1air_base_core::encoding::base64, pas de réimplémentation),ssh-ed25519seul, ligne bornée à 8 Kio, lignes vides et#non-erreurs, erreurs distinctes et actionnables (OptionsUnsupported≠UnsupportedAlgorithm: la correction n’est pas la même) ; options refusées explicitement (les accepter en silence reviendrait à prétendre les appliquer). (Dépassé depuis ADR-128 : les options du périmètre v2 sont désormais lues ; seulfrom=reste refusé, par décision — cf. l’entrée « OPTIONSauthorized_keysFAIT » plus haut.) Deux vérifications de fond : le nom d’algorithme à l’intérieur du blob doit correspondre à celui annoncé (sinon on enregistrerait une clé sous un type qui n’est pas le sien) et le blob doit être entièrement consommé (32 octets de clé, zéro octet excédentaire). Commentaire conservé en octets bruts (Principe 3 — rien ne garantit qu’il soit UTF-8) ; à l’écriture, un commentaire non-UTF-8 est écrit vide (le champ capnp estText, et le commentaire est non signifiant), contrat documenté — la CLI (K.2) refusera en amont. Import tout-ou-rien avec numéro de ligne en erreur (un import partiel laisserait un magasin dont personne ne connaît le contenu). Preuves : 17 tests ajoutés (79 tests lib air-sshd verts), dont la chaîne de bout en bout ligne OpenSSH réelle → magasin binaire → lecteur d’autorisation et la vérification du mode 0600 réel sur disque ; fuzzfuzz_air_sshd_authorized_keys_text20 M exec, 0 crash (invariant renforcé : toute entrée acceptée doit survivre à l’aller-retour vers l’artefact scellé — un import produisant un magasin illisible serait un bug silencieux). Zéro nouvelle dépendance ; arêtes de couche inchangées (2→1). K.2 ✅ — CLIair-sshd authorized-keys(list/add/remove/import), gatée ADR-101. K.1 avait l’écrivain ; K.2 en fait une commande utilisable, avec les deux choses qu’une commande d’administration doit avoir : une cible explicite et une autorisation. Cible = la cascade d’ADR-096 côté écriture : son propre magasin (défaut),--user NOM, ou--system. Gate : son propre magasin est libre (le noyau tranche, c’est son fichier) ; le magasin système ou celui d’un autre utilisateur exige le mot de passe admin (credentialroot, temps constantair-crypto), fenêtre de bootstrap ADR-101 respectée telle quelle.listest lecture seule, jamais gatée (cohérentair-account user list, A.7). Le gate est un trait injectable (AdminGate) — c’est ce qui permet de prouver par test que le refus précède toute écriture, sans monter une base de credentials. Duplication assumée et bornée avecair-account-cli(~25 lignes de politique) : les deux dérivent d’ADR-101 (source normative), etair-account-cliest un crate binaire sans bibliothèque à consommer — à revoir si un 3ᵉ consommateur apparaît. Détails qui comptent : magasin corrompu ⇒ refus d’écrire (l’écraser détruirait des clés qu’on ne sait plus lire) ; magasin absent ⇒ vide (on peut ajouter une 1ʳᵉ clé) ; import fusion par défaut,--replaceexplicite, tout-ou-rien avec numéro de ligne ;removerefuse un sélecteur ambigu (deux clés, même commentaire) — l’empreinteSHA256:…(format OpenSSH, viaAirHashcouche 1) désigne sans ambiguïté ; clé déjà présente signalée (l’écrivain déduplique, mais l’administrateur doit le savoir) ; mot de passe lu ligne par ligne sur stdin, jamais dansargv(visible dansps). Preuves : 22 tests (18 in-process + 4 sur le vrai binaire), 97 tests lib air-sshd verts. Les tests binaire sont volontairement en lecture seule : les chemins d’écriture visent le magasin réel du testeur (~/.config/air/sshd/…) — les exercer polluerait sa machine ; ils sont prouvés in-process sur chemins temporaires. Toute la logique vit dans la bibliothèque (le binaire démon est exclu de la couverture) ; le binaire ne fait que collecterargv, brancher le gate réel et lire stdin. Zéro nouvelle dépendance ; couches inchangées (2→1). K.3 ✅ — la cascade câblée : magasin système + magasin par-utilisateur. C’était le trou identifié à l’inventaire :FileAuthorizedKeys::with_system_fileexistait et était testé, mais inatteignable depuis la configuration. Champ additifAuthorization.systemAuthorizedKeysPath @1 :Text(id croissant, jamais renuméroté — ADR-012) ; code capnp régénéré (tool C++ 1.1.0 + capnpc-rust 0.26.0), et régénération vérifiée reproductible : seul le fichier du schéma modifié change, les trois autres ressortent byte-identiques. Câblé de bout en bout : lecteur, écrivain, démon (FileAuthorizedKeys::with_system_filequand un chemin est déclaré, sinon par-utilisateur seul), CLI (config getl’affiche,config set system-authorized-keys/…-clearl’éditent) et export/import JSON — ce dernier point n’est pas cosmétique : omettre le champ du JSON l’aurait perdu silencieusement à chaque aller-retour (ADR-032, zéro discard silencieux). Chemin relatif REFUSÉ (il serait résolu depuis le cwd du démon : imprévisible et silencieusement faux). Compatibilité ascendante PROUVÉE, pas supposée : un artefact écrit avant K.3 (fabriqué avec un builder ne posant que les champs d’avant) se relit enNone— Cap’n Proto rend une chaîne vide sur un champ absent, qu’on distingue explicitement d’un chemin vide. Preuve de bout en bout : un utilisateur sans aucun magasin personnel se connecte grâce à une clé du magasin système seul (SYSTEM_STORE_OK), avec son contre-test — la même clé est refusée quand la config ne déclare pas de magasin système, sans quoi le test positif ne prouverait rien. Preuves : 6 tests ajoutés (3 config, 2 démon bout-en-bout, 1 aller-retour JSON étendu), suiteair-sshdentièrement verte. Zéro nouvelle dépendance ; couches inchangées (2→1). Volet clés (K.1→K.3) TERMINÉ. S.1 ✅ — cœur sans-IOair-sftp-proto(nouvelle crate couche 2). Le volet SFTP démarre par son cœur pur (ADR-091 normatif) : il transforme des octets en intentions typées et des décisions en octets, sans jamais toucher au réseau, au système de fichiers ni à l’horloge — il ne sait même pas d’où viennent les octets.no_std + alloc,#, zéro dépendance externe (seulair-base-core, couche 1, pour la monnaie d’erreurs). Cinq modules :codec(primitives de fil, lecture bornée),framer(découpage en paquets, plafond 1 Mio),attrs,message(les 20 messages v3),session(ordre du dialogue). Décisions de conception qui comptent : (a) crate séparée d’air-ssh-proto— SFTP est un protocole distinct, seulement transporté par SSH ; le séparer le rend fuzzable sans monter la moindre session SSH ; (b) codec propre (~120 lignes) plutôt que couplage entre deux protocoles sans raison d’évoluer ensemble ; (c) attributs modélisés enOption<T>par champ, masque dérivé et non stocké — il ne peut donc pas mentir, et « tronquer à 0 » reste distinct de « ne rien dire de la taille » ; (d) une longueur annoncée n’est jamais crue :Readerne fait que découper une tranche existante, aucune allocation n’est dimensionnée sur unuint32venu du réseau ; (e) octets excédentaires = refus — agir sur un message compris à moitié, c’est agir sur une intention devinée ; (f) noms et chemins en octets bruts de bout en bout (Principe 3 : en v3 un nom de fichier n’a aucun encodage garanti). Deux verrues v3 assumées et documentées :SYMLINKa ses arguments inversés chez OpenSSH par rapport au brouillon — on lit cible d’abord, comme OpenSSH, parce que suivre le texte donnerait un serveur conforme sur le papier et inutilisable en pratique (liens créés à l’envers) ; etNAMEporte unlongnamed’affichage, produit en octets sans présumer de la locale.EXTENDEDest décodé juste assez pour répondreOP_UNSUPPORTEDavec le bon identifiant — un silence bloquerait un client qui attend une réponse par requête. Preuves : 44 tests dont 6 property-based, avec un modèle de référence sur la machine d’état (quelle que soit la séquence, une requête n’est exécutée que si unINITvalide l’a précédée) ; fuzzfuzz_air_sftp_packet17 442 886 exec, 0 crash, sur la chaîne entière (framer → décodage → session) — fuzzer les morceaux séparément laisserait passer les défauts de leur jointure. S.2 ✅ — pilote SFTP en lecture seule : le vrai client OpenSSH liste et rapatrie. Le sous-systèmesftpest accepté sur liste blanche (connection.rsrefusait toutsubsystem; une liste noire laisserait passer ce qu’on n’a pas prévu), et le canal est servi par une boucle strictement réactive — un paquet entre, une réponse sort : rien à pomper en sens inverse, donc aucune concurrence à orchestrer, contrairement au shell interactif d’E.3. Le moteur (sftp.rs) est une unité autonome : requête entrée → réponse sortie, avec entre les deux uniquement des appels couche 1. Il ne connaît ni le canal, ni le transport, ni le processus qui l’héberge — c’est le point de conception qui compte : en S.3 il sera déplacé dans le worker privsep sans que sa logique ni ses tests changent. Décisions notables : (a)O_PATHpour lesstat— ouvrir un FIFO en lecture bloquerait le serveur jusqu’à ce qu’un écrivain se présente (test dédié avec un vrai FIFO) ; (b)OPENDIRliste d’un coup au lieu de tenir un curseur : les tranches deREADDIRsont stables (le client ne voit pas le répertoire changer sous lui) et aucun descripteur ne reste ouvert pendant la conversation ; (c) l’ouverture en écriture est refusée à l’OPEN, pas au premierWRITE— accepter l’ouverture ferait croire au client que le transfert a commencé ; (d) aucune vérification d’accès en espace utilisateur : le noyau tranche, on traduit sonerrno(NOT_FOUND→NO_SUCH_FILE,PERMISSION_DENIED→…) — la granularité compte, unFAILUREgénérique n’apprendrait rien au client ; (e) handles opaques et jamais réutilisés (un handle fermé ne peut pas redevenir valide), table bornée ; (f)READborné à 256 Kio — le tampon est dimensionné sur la demande bornée, jamais sur leuint32brut. Lelongnameest produit avec une conversion civile faite maison (~15 lignes, la couche 1 n’expose pas de calendrier et icu4x est hors fermeture, ADR-090), UTC assumé et affiché plutôt que de prétendre connaître le fuseau du client. PREUVE RÉELLE : le binairesftpd’OpenSSH se connecte àair-sshd,ls -lrend noslongnamecorrectement (-rw-rw-r-- 1 1000 1000 4096 Jul 25 08:48 charge.bin) etgetrapatrie 4096 octets octet-pour-octet identiques. 20 tests sur le moteur (cycle open/read/close, EOF, FIFO non bloquant, LSTAT ≠ STAT, refus explicite des 7 opérations d’écriture, borne de la table, dates civiles dont le 29 février et les dates négatives). (Piège de test consigné : le~/.ssh/configde la machine injecte 8 clés d’agent AVANT celle de-iet épuise la limite d’essais U.4 — il faut isoler le client avec-F /dev/null+IdentityAgent=none. Ce n’est pas un défaut serveur : U.4 faisait exactement son travail.) Reste S.3→S.5. S.3 (en cours) — ADDITIF COUCHE 1fork_process: la primitive n’existait pas. L’ADR-104 §4 supposait unforksansexecdisponible. Vérification à l’ouverture de S.3 : il ne l’était pas — leclone3d’air-processest enfermé dansAirCommand::spawn/spawn_process, dont l’enfant va toujours jusqu’àexecve(run_child). Aucune API couche 1 ne rendait la main dans l’enfant. Arbitrage porté au BDFL entre créer la primitive (descellement additif couche 1 : on fait entrer dans une couche scellée une primitive dont la sûreté ne peut pas être vérifiée par le compilateur) et ré-exécuter le binaire (/proc/self/exe --sftp-worker: zéro surface nouvelle, cheminspawndéjà audité, image mémoire neuve — ni anneauio_uring, ni clés d’hôte héritées — au prix d’unexecpar session). DÉCISION BDFL 2026-07-25 : créer la primitive.air_process::fork_processajoutée —unsafe fn(le contrat « ce que l’enfant a le droit de faire » est reporté sur l’appelant et doit être lu ; précédents couche 1 :air_thread::create_thread,air_memory::unmap), rendantAirForkResult::Parent(AirProcess)/Child. Le parent obtient unpidfd, jamais un PID nu (doctrine du projet) ; aucun tube n’est monté (l’appelant prépare sessocketpairavant, et ferme dans l’enfant ce qu’il ne doit pas hériter). La doc distingue les deux régimes : mono-fil (cas d’air-sshd, mono-réacteur — le danger classique du verrou d’allocateur ne s’applique pas) et multi-fils (restriction async-signal-safe jusqu’àexecve/exit_group). 3 tests qui forkent réellement : l’enfant revient dans le code déjà chargé (ce qu’unspawnn’atteindrait jamais) et son code de sortie remonte ; le parent tient unpidfdvalide ; les espaces d’adressage sont effectivement séparés (mémoire héritée vue par l’enfant, écriture de l’enfant invisible du parent, PID distinct). ADR-104 §4 amendé (amendement daté, texte d’origine conservé). Descellement additifcouche-1-v2.xà acter au re-sceau. Reste S.3 (suite) : câbler le worker privsep sur cette primitive, puis S.4→S.5 (écriture, interop scellée en CI + transfert ≥ 100 Mio).⏱ 2026-07-26 — S.4 ✅ : écriture SFTP, prouvée par le vrai client OpenSSH (
put,mkdir,rename,rm). Le moteur (sftp.rs) n’est plus en lecture seule :OPENaccepte les drapeauxSSH_FXF_*d’écriture/création (traduits enOpenFlags, mode de création pris des attributs), etWRITE/MKDIR/RMDIR/REMOVE/RENAME/SETSTAT/FSETSTAT/SYMLINK/READLINKsont implémentés. Aucun descellement de couche 1 — comme l’ADR-104 §Conséquences le prévoyait,air-filesystemexposait déjà tout :AirFile(path-based,write_at/set_length) pourOPEN+WRITE, et le managerAirFileManager(DirFd::Cwd+CStr) pour mkdir/rmdir/unlink/rename/symlink/ readlink/chmod/chown/utimens. Zéro contrôle d’accès en espace utilisateur (comme en lecture) : le noyau décide sur l’identité du processus, on traduit l’errno.WRITEest tout-ou-erreur (boucle jusqu’à épuisement) ; les chemins àNULinterne sont refusés (jamais tronqués) ;SETSTAT/FSETSTATn’appliquent que les champs présents (taille viaftruncate, mode, propriétaire, dates). Preuve d’interop réelle :tests/sftp_interop.rs— le binairesftpd’OpenSSHputun fichier de 8 Kio,mkdirun dossier,renamedans ce dossier,rmun fichier ; contenu vérifié octet pour octet sur le disque servi. Couverture moteursftp.rs97 % lignes (42 tests in-process : put, offsets, EXCL sur existant, handle inconnu, mkdir/rmdir, remove, rename, symlink+readlink, setstat/fsetstat taille+mode+propriétaire+dates, NUL refusé, écriture d’un handle lecture-seule refusée côté noyau). Reste S.5 : interop scellée en CI (sftpdepuis un Mac) + gros transfert ≥ 100 Mio (fenêtres de canal, checksum).⏱ 2026-07-26 — CAMPAGNE DE SÉCURITÉ ADR-105 CLOSE :
air-sshdconfronté à toutes les attaques SSH connues, tous critères atteints. CHANTIER air-sshd CLOS. Attaquant carbon (carbon.net:52222) → cible linux-air (0 glibc) sur speedy:2222. 6 axes (journaldocs/notes/campagne-securite-air-sshd-fr.md) : (1) audit — Terrapin non vulnérable, crypto moderne-only ; (2) DoS pré-auth — finding DHEat corrigé (throttling par source : concurrence #438 + rate-limit token bucket #439), validé (300 KEX → 27/273 refusées ; handshake légitime distinct inchangé ~0.05 s pendant l’attaque) ; (3) constant-time — dudect sur la comparaison de tag|t|≈1–2(seuil 10) ; (4) anti-énumération — dudect in-memory|t|<3+ réseau indistinguable ; (5) brute-force — publickey-only (zéro surface password) + plafond 6 essais/connexion (U.4) → ≤60 essais/s/source ; (6) SYN flood — SYN cookies + joignable pendant + récupéré après. Seul finding (axe 1) corrigé et validé (axe 2) ; limitation non exploitable consignée (FileAuthorizedKeysnon constant-time par construction, écart ~µs noyé sous ~24 ms réseau — durcissement différé, décision ouverte). Harnais dudect in-tree gatésAIR_DUDECT=1(hors gate CI).⏱ 2026-07-26 — RE-SCEAU
couche-1-v2.4(tag signé GPG, main 9491018). Sceau additif depuiscouche-1-v2.3(E.3), couvrant deux campagnes — aucune API scellée modifiée ni cassée. ADR-100 base de comptes binaire : nouvelle crateair-account-schema(schémas Cap’n Proto passwd/group/shadow) + surface binaire d’air-account(build/write/parse/import_*_to_binary+ projection/etctexte 0644 / shadow 0600), l’API texte historique restant intacte. ADR-104 SFTP :air-process::fork_process(unsafe fn, fork sans exec,AirForkResult— support du worker privsep) +air-filesystem::write_atomic_with_mode. linux-air : nouvelle crateair-std-entry(_start) +air-base-coresys/time bridgé couche 1 (#417). Barrière verte au sceau (cargo xtask barrier: fmt + clippy -D warnings + test workspace ; WARN advisory) ; couverture verte au merge de #436 (= HEAD). Reste pour clore le chantier air-sshd : la campagne de sécurité ADR-105 (carbon attaque air-sshd linux-air sur speedy:2222).⏱ 2026-07-26 — S.5 ✅ : interop SCELLÉE en CI + gros transfert ≥ 100 Mio, fenêtre de canal respectée par construction. C’EST LA CLÔTURE FONCTIONNELLE D’
air-sshd. (1) Contrôle de flux correct. Le TODO S.5 du pilote (connection/sftp_serve.rs) est fermé :send_sftp_datan’émet plus jamais au-delà de la fenêtre annoncée par le pair. Quand le crédit manque, il attend unWINDOW_ADJUST(au lieu de saturer à 0 et dépasser) ; les requêtes que le client pipeline pendant cette attente sont mises dans une file bornée (MAX_PENDING_REQUESTS, anti-DoS : un client qui inonde sans rendre de fenêtre →ENOBUFS) et rejouées ensuite, sans réordonnancement ni perte. Mesure préalable : le transfert de 100 Mio passait déjà avec le modèle séquentiel (OpenSSH recrédite assez vite), mais laisser un serveur réseau capable de dépasser la fenêtre d’un pair contredit l’éthos « sur-sécuriser puis dégraisser » — d’où la correction par construction. (2) Preuve :tests/sftp_interop.rs— le vrai clientsftpd’OpenSSHgetun fichier de 100 Mio, vérifié octet pour octet par tranches (mémoire bornée, correct même sur Pi 4). (3) Scellage CI :AIR_SFTP_INTEROP_REQUIRED=1(même motif que le gate ABI) — l’absence desftp/ssh-keygendevient un échec (fini le skip silencieux) et active le gros transfert ; posé dans le stepcargo test --workspace(carbon a openssh-client, prouvé par les runs S.2/S.4).sftp_serve.rsreste exclu de la couverture (le démon estSIGKILLé en fin de test →.profrawjamais écrit) : la preuve valide le code sans pouvoir le mesurer ; levée = drain SIGTERM. ⏱ 2026-07-26 — VAGUE 1 #1 : ADR-108air-keystoreÉCRIT (Accepté, décision BDFL). Premier ADR de la vague 1 (keystore → certs+CA → ML-KEM → air-log → air-sandbox), après le cadre CI/CD (ADR-107). Crée le manager de domaine couche 1 ([ADR-077]) qui centralise le cycle de vie des clés/secrets aujourd’hui éparpillé (clé d’hôte via air-crypto,authorized_keysvia air-filesystem) : génération (délègue air-crypto, primitives pures), import/export, lookup, péremption, révocation (RevokedKeys), magasin d’hôtes connus (known_hosts, TOFU). Stockage binaire scellé (AIRCFGv1+capnp [ADR-040], motif [ADR-096],0600). Interop OpenSSH requise aux frontièresformats(clés pub/priv OpenSSH + PEM + certs — fuzz obligatoire) ; cœur interne binaire. Deux faces : bibliothèque non privilégiée (gros de l’usage) + serviceair-keysign(couche 2, socket-activé, jamais setuid, oracle-safe) pour hostbased/clé d’hôte. Frontières actées : format wire des certs = couche 2 (air-ssh-proto), CA = orchestration (pas monolithe) ;SecurityManager[ADR-089] distinct (politique, consomme). Reportés vague 2 : CLIair-keystore(ssh-keygen élargi + JSON)
- extraction
air-json. Non implémenté (ADR = décisions) : la crate reste à produire. SUMMARY + registre (v3.43) + suivi à jour,mdbook buildà re-vérifier.⏱ 2026-07-26 — VAGUE 1 #2 : ADR-109 Certificats SSH + CA ÉCRIT (Accepté, décision BDFL). Deuxième ADR de la vague 1 (après keystore ADR-108). Le format wire du certificat + sa vérification vivent dans
air-ssh-proto(couche 2), motif sans-IO fuzzable ([ADR-091]), appelant air-crypto (Ed25519 verify pur). Format OpenSSHssh-ed25519-cert-v01@openssh.com(interop) + moderne-only ; options critiques inconnues = rejet (fail-closed). CA = orchestration (pas monolithe), layering strict :air-keystore(couche 1) détient clé CA + magasin de CA de confiance + révocation (KRL, interop OpenSSH) ; l’émission est couche 2 (keystore/air-keysignfournit la clé → proto sérialise le to-be-signed → air-crypto signe → proto assemble) ; la vérification (signature + expiry + principal + révocation + options) consommée par air-sshd/air-ssh, politique auSecurityManager[ADR-089]. Deux types : cert utilisateur (remplaceauthorized_keysN×M) et cert hôte (remplace TOFUknown_hosts, débloque le provisioning air-desktop-host — ADR séparé). CLIcert issue/inspect/verify/revoke= vague 2. Non implémenté (ADR = décisions). Vague 1 : reste ML-KEM → air-log → air-sandbox. SUMMARY + registre (v3.44) + suivi à jour.⏱ 2026-07-26 — VAGUE 1 #3 : ADR-110 KEX post-quantique ML-KEM ÉCRIT (Accepté, décision BDFL). Ferme le seul vrai retard de sécurité (défaut OpenSSH ≥9.9 ; « harvest now, decrypt later »). Doctrine crypto : PQ = risque très élevé → crate pur-Rust d’abord, pas de DIY treillis. Adopte
ml-kem(RustCrypto, pin =0.3.2) : pur Rustno_stdsans heap, zérounsafe, constant-time, deps C-free confirmées (aucun cpufeatures/cc/libc — déterminant sur*-linux-air), MIT/Apache, règle 80 % OK. Re-validé à notre barre (upstream non audité) : vendoré+piné, KATs FIPS 203, notre dudect encaps/décaps, fuzz décaps (surface réseau), Miri, proptest. Écrit par nous (faible risque) : combineur hybridemlkem768x25519dansair-crypto(X25519 dalek ⊕ ML-KEM-768, concat+hash IETF/OpenSSH). KEX dansair-ssh-proto(couche 2, sans-IO), méthode préférée, replicurve25519-sha256;sntrup761non implémenté. Non implémenté (ADR = décisions). Vague 1 : reste air-log → air-sandbox. SUMMARY + registre (v3.45) + suivi à jour.⏱ 2026-07-26 — VAGUE 1 #4 : ADR-111
air-logÉCRIT (Accepté, décision BDFL). OpenSSH journalise vers syslog/journald ; Air ne publie que sur AirCom ([ADR-001], bus programmatique) → surface admin/journalctlmanquante.AirLogManager([ADR-077]) = sink opérationnel distinct d’AirCom (pont opt-in), au-dessus d’air-socket,no_std+alloc. journald natif écrit par nous (socket datagram, champsCLÉ=valeurlongueur-préfixés, pas delibsystemdC, [ADR-088]) + fallback stderr/fichier. Anti-injection OBLIGATOIRE (données attaquant : user/comment/IP → neutraliser\n/contrôles ; cadrage binaire journald protège par construction = argument vs syslog texte ; fuzz encodeur). API typée (niveaux + champs structurés). Envoi datagram fire-and-forget drop-safe (jamais bloquer le réacteur sous DoS). Abstraction de sink (journald/fichier/futur journal Air natif). Non implémenté (ADR = décisions). Vague 1 : reste air-sandbox (dernier). SUMMARY + registre (v3.46) + suivi à jour.⏱ 2026-07-26 — VAGUE 1 #5 (DERNIER) : ADR-112
air-sandboxÉCRIT → VAGUE 1 CLOSE. Crée le mécanisme de confinement seccomp/Landlock (distinct duSecurityManager[ADR-089] = politique) : builders d’allow-list haut niveau → Landlock ruleset + seccomp-BPF sans fuite couche 0, profils prédéfinis (pre-auth/sftp-worker), compilateur seccomp allow-list→BPF MAISON (décision BDFL : pas delibseccompC, [ADR-088], arch-aware x86_64/aarch64,no_new_privs, fuzzé + tests d’efficacité on-target), fail-closed (opt-out explicite journalisé via air-log [ADR-111]), et répare la fuite couche 0 d’air-process(with_seccomp_filter/with_landlockprennent désormais les types haut niveau). Non implémenté (ADR = décisions).✅ VAGUE 1 D’ADR CLOSE (triage des écarts OpenSSH) : ADR-108 keystore → 109 certs+CA → 110 ML-KEM → 111 air-log → 112 air-sandbox, précédés du cadre ADR-107 (pipeline CI/CD linux-air) et du correctif CI docs-only skip (#451, plus de recompilation inutile sur speedy/Pi pour un ajout de doc). Vague 2 restante (déjà tranchée, à rédiger) : air-agent, forwarding & multiplexing, config binaire capacités, extensions SFTP+statvfs, hostbased+provisioning, outillage air-json + CLI air-keystore, doctrine sourcing crypto. Différés : FIDO/HID, ControlMaster,
-DSOCKS, AuthorizedKeysCommand, AuthenticationMethods. SUMMARY + registre (v3.47) + suivi à jour.⏱ 2026-07-26 — VAGUE 2 OUVERTE, #1 : ADR-113 doctrine de sourcing crypto ÉCRIT (Accepté, décision BDFL). Formalise (raffine [ADR-034]/[ADR-024]) la doctrine déjà appliquée en vague 1 : arbre de sourcing (pur-Rust C-free/os-free → ne pas porter le C → DIY sérieux), asm OK / C non (invariant os-free [ADR-088] ; piège
cpufeatures/libc aarch64), standard DIY (KATs/dudect/fuzz/Miri/proptest/zeroize), gradient de risque (formats=DIY possédés, symétrique=RustCrypto, asym-corps=dalek sauf C, PQ=crate auditée), fiable > performant. Plan vague 2 (9 ADR, 113→121) : 113 doctrine crypto · 114air-json· 115 CLIair-keystore· 116air-agent· 117 redirections & multiplexing · 118 config binaire capacités · 119 extensions SFTP+statvfs · 120 hostbased · 121 provisioning air-desktop-host. PROGRESSION vague 2 : ✅ 113→121 COMPLÈTE. Registre → v3.56. Les deux vagues du triage des écarts OpenSSH (108→121, + cadre CI ADR-107) sont rédigées et mergées. Reste = IMPLÉMENTATION (ADR = décisions) + différés (FIDO/HID, ControlMaster, -D SOCKS, AuthorizedKeysCommand, AuthenticationMethods).Volet SFTP d’ADR-104 COMPLET (S.1→S.5). Reste pour clore le chantier air-sshd : le re-sceau
couche-1-v2.4(décision BDFL : après clôture), puis la campagne de tests de sécurité (ADR-105).⏱ 2026-07-25 — CORRECTIF ABI C : un défaut réel, et surtout SIX gates qui se sautaient en silence. Découvert en passant la suite complète du workspace pendant le chantier SFTP. Le défaut :
air_c.hdéclaraitint pipe(int *fds)alors que son propre commentaire trois lignes plus haut écrivait « POSIX.int pipe(int[2])» ; glibc déclareint pipe(int __pipedes[2]), et GCC refuse la contradiction (-Werror=array-parameter). Un consommateur C incluant<unistd.h>et notre en-tête — le cas normal — ne compilait pas. Même divergence latente surpipe2etsocketpair(leurs doc-comments écrivaient déjàint[2]). Corrigé dans le GÉNÉRATEUR (postprocess_air_c), pas à la main, puis en-tête régénéré par cbindgen 0.28.0 pinné. La cause racine, plus grave que le défaut : SIX gates de stabilité ABI se sautaient silencieusement en rendant « ok » —c_abi_conformanced’air-libc-capi(dès quelibair_c.son’était pas à côté du binaire de test, ce qui est le cas en CI) et les quatreheader_committed(dès quecbindgenest absent — et la CI ne l’installait pas). Prouvé, pas supposé : le log CI du dernier run surmainmontre le test « ok » sans avoir rien compilé, et le retrait local de la.sole fait repasser au vert instantanément. C’est ainsi qu’air_object.havait dérivé de ses sources sans que rien ne le signale (dérive corrigée par la régénération). Correctif systémique :AIR_ABI_CONFORMANCE_REQUIRED=1(déjà posée par la CI, déjà honorée parair-value-c/air-object-c) couvre désormais les six — outil ou artefact manquant = ÉCHEC, jamais un skip muet ; et la CI installe cbindgen (version épinglée, ADR-025 : une version flottante ferait diverger le diff). Vérifié dans les deux sens : sans la.soet conformité exigée ⇒ échec explicite ; sans elle et non exigée ⇒ skip signalé. Piste écartée après mesure : élargir la source C à<sys/socket.h>+_GNU_SOURCEpour couvrirsocketpair/pipe2— impossible,air_c.hest un en-tête libc de remplacement (il définit lui-mêmeAF_INET,struct sockaddr,struct msghdr…) et ne peut pas coexister avec ceux de glibc ; ~20 redéfinitions. Consigné plutôt que forcé. (Au passage : le piègeEACCESd’ADR-031 mordait aussi le dépôt local — des artefacts root du 2026-07-23 danstarget/tests/trybuildfaisaient échouercompile_fail; propriété rétablie. C’est exactement ce que le filet pré-checkout de #419 empêche désormais côté CI.) SUITE — le durcissement a immédiatement mordu, et c’était juste. Première CI avec la variable posée :AIR_ABI_CONFORMANCE_REQUIRED=1 mais libair_c.so introuvable. Cause réelle, pas un faux positif :air-libc-cest déclarécrate-type = ["cdylib"]seul — aucune crate ne le lie, donccargo test --workspacen’a jamais eu de raison d’émettre la.so. C’est exactement pourquoi le gate se sautait depuis toujours : l’artefact qu’il devait confronter au consommateur C n’était pas produit. Correctif : étapecargo build -p air-libc-cavant les tests, qui fait réellement tourner le gate au lieu de le faire semblant. Séquence prouvée dans les deux sens en local : sans la.so⇒ échec explicite nommant la cause ; aprèscargo build -p air-libc-c⇒ test vert. (Rappel : sur le poste local, la.soexistait parce qu’uncargo build -p air-libc-cantérieur l’avait produite — d’où un test qui passait en local et échouait en CI.) PUIS l’étape de couverture a expiré (40 min) — sur un défaut SANS RAPPORT, révélé parce que #426 est la PREMIÈRE PR à dépasser l’étapeTests. Blocage dansair-process::drop_to_user_resolves_and_drops_under_root:clone3sansexecpuis, dans l’enfant, du code qui alloue (drop_to_user→air-account: lecture + décodage capnp). Sur notre cible c’est résolu (air_alloc::reset_after_fork, ADR-056 D8, remet le futex de l’arène àUNLOCKED) ; sur l’hôte gnu l’allocateur est celui de la glibc,clone3en syscall brut court-circuite sespthread_atfork, rien ne réinitialise ses verrous — et si un autre fil du harnais le détient à l’instant dufork, l’enfant s’y bloque pour toujours. Latent depuis toujours, déclenché par la charge de la machine (builds locaux concurrents de la CI). Le défaut n’existe que parce que le test tourne contre la glibc. CONTOURNEMENT TEMPORAIRE POSÉ, MARQUÉ COMME DETTE DU CHANTIER PAL : les 5 tests deprivilege_root.rsprennent un verrou de sérialisation commun, de sorte qu’aucun autre fil du processus ne puisse être dansmallocpendant lefork. Bloc de commentaire explicite en tête de fichier : pourquoi, pourquoi c’est temporaire, et « À RETIRER » quand la suite tournera sur*-unknown-linux-air— le reset d’arène rendra la sérialisation inutile. Le verrou est pris par chacun des 5 tests (un seul oubli rouvrirait la course) et récupère lepoisoning(un test paniqué ne doit pas cascader en échecs sans rapport). Vérifié SOUS ROOT (le régime où le fork s’exécute réellement, les tests se sautant en non-root) : 3 exécutions consécutives vertes. Ce n’est pas un correctif — c’est une dette assumée, tracée, et dont la levée est le critère de réussite du chantier PAL.⏱ 2026-07-25 —
check-targetétendu : couverture de LIVRAISON (couplage gate ↔deb). Le gate [#415] prouvait qu’un exécutable déclare la bonne cible (linux-air/host-toolallowlisté) — mais pas qu’il est livré : la table des paquets decargo xtask debétait une liste codée en dur dansdeb.rs, qu’un nouvel exécutable pouvait manquer silencieusement (compilé linux-air, jamais packagé, donc jamais sur la machine). Correctif : la table devient une source unique partagée (xtask/src/packaging.rs), consommée pardeb(production) et parcheck-target(gate) ⇒ le couplage est tenu par le compilateur, pas par un parseur de source. Deux règles ajoutées, dans les deux sens : (e) tout crate produisant un exécutablelinux-airest packagé — ou justifié dansUNPACKAGED_LINUX_AIR(vide aujourd’hui : le prochain exécutable devra faire un choix conscient) — et chacun de ses binaires est installé par le paquet ; (f) réciproquement, tout paquet désigne un crate qui existe, produit un exécutable, estlinux-air, et produit bien chacun des binaires déclarés. (f) attrape la dérive inverse (crate renommé, binaire supprimé) quedebne signalerait qu’au build, et seulement sur un runner Linux-air. Gate vu ROUGE sur les 3 modes d’échec (binaire fantôme déclaré / livrable retiré de la table / binaire produit mais non installé), messages actionnables, puis vert : 3 exécutables, 2 livrables packagés, 0 non packagé. Extraction des binaires produits fidèle à cargo ([[bin]]explicites, auto-découvertesrc/bin/*.rs,src/main.rssauf s’il est renommé par un[[bin]]— cas réelair-account-cli→air-account). ADR-103 non modifié ; doctrine consignée dansdocs/outillage-xtask.md(sectioncheck-targetcréée, sectiondebrafraîchie : elle décrivait encore un paquet uniqueair-account).⏱ 2026-07-25 — HYGIÈNE CI : artefacts root post-couverture (piège
EACCES). Effet de bord de la mesure en root (ADR-031) : les artefacts écrits soustarget/llvm-cov-targetappartiennent à root, alors que legit clean -ffdxdu checkout tourne en non-root ⇒ un artefact root oublié bloque tous les runs suivants enEACCES(le workspace_work/<dépôt>/<dépôt>est partagé par tous les jobs du dépôt sur un runner), jusqu’à unrm -rfmanuel sur speedy — incident survenu pendant le chantier linux-air. L’étape de restauration existante (if: always(),chown -R target) ne suffit pas :always()ne survit ni à un runner tué ni à une coupure, et un run annulé (cancel-in-progress, déclenché par un simple re-push de PR) laisse une fenêtre. Correctif — deux filets : (1) l’étape de fin de job est élargie au workspace entier et àCARGO_HOME(les étapes root tournent ensudo -E⇒ uncargoroot qui télécharge une dépendance écrit dans le registre de l’utilisateur : trou latent fermé au passage) ; (2) nouveau filet pré-checkout sur les 6 jobs — reprise de propriété du workspace avant le checkout, idempotent, no-op quand rien ne traîne, tolérant sisudo -nmanque. C’est le filet (2) qui rend la CI auto-réparante quelle que soit la façon dont le run précédent est mort. Étaperun:inline (une action composite locale exigerait un checkout préalable — précisément l’étape qui échoue). ADR-031 non modifié (décision inchangée, immuable) ; doctrine consignée dansdocs/CI.md.⏱ 2026-07-25 — CHANTIER conformité linux-air des exécutables (ADR-103, autonome). Directive BDFL : tout exécutable Air (air-account, air-ssh, air-sshd, suivants) doit être compilé pour
*-unknown-linux-air— lié à NOTRE std linux-air (PAL couche 1, sans libc C, static-pie), jamais à la std gnu/glibc. ADR-103 ratifié (Accepté) : livraison linux-air only ; la std gnu reste un banc de test hôte (couverture/fuzz), jamais un livrable ; marquage par crate + gatecheck-target. Le déploiement a servi de révélateur : produire un vrai programme (pas justehello-std-air, qui ne fait qu’unprintln!) a exigé 5 correctifs de la std linux-air, tous mergés — (1)restricted_std:os=airajouté à l’allowlist destd/build.rs(débloque toute crate tierce utilisantstd: RustCrypto/subtle, capnp) [#410] ; (2).weak __air_rt_sigreturn_trampoline: dédup du symboleglobal_asm(air-sys-syscall compilé 2× — closure std ET app) [#408] ; (3) crateair-std-entry: point d’entrée ELF_start(std exporte__air_startmais pas_start) [#409] ; (4)sys/args/air.rs:std::env::args()ne remontait AUCUN argument (patch câblaitsys/envmais passys/args) → binaires ignoraient leur ligne de commande [#412] ; (5)sys/time/air.rs:std::timenon câblé (air-sshd panique « time not implemented ») → Instant/SystemTime viaair_sys_syscall::time::clock_gettime[#414]. Packaging :cargo xtask debbasculé sur linux-air (-Z build-std+ rust-src patché + cible JSON ; garde-foureadelf= 0NEEDED; 2 paquets air-account + air-sshd) [#413]. Enforcement : gatecargo xtask check-target[#415] — chaque exécutable déclare[package.metadata.air] target = "linux-air"(ou"host-tool"allowlisté = xtask seul) ; un exécutable gnu ou un crate non identifié = échec de barrière ; branché en CI. ✅ DÉPLOYÉ sur les 3 machines (2026-07-25) :air-account+air-ssh+air-sshden*-unknown-linux-airnatif, static-pie, 0 glibc sur speedy (x86_64), carbon (x86_64) et raspi-srv-2 (aarch64) ; air-sshdactivesur :2222 (parallèle OpenSSH), air-account lit la base binaire (52/54/32 comptes). ADR-103 réalisé pour ces 3 exécutables. CORRECTION #417 (2026-07-25) : le fix #5 (temps) violait la règle dure de layering —sys/time/air.rsbindaitair_sys_syscall::time::clock_gettime= couche 0 EN DIRECT (le PAL de la std binde la couche 1, JAMAIS la couche 0). Recâblé surair-base-core::time(couche 1) via son API publique déjà existante (AirDuration::to_std_saturating,AirSystemTime::as_unix_seconds/subsec_nanos) — aucun additif couche 1, le raccourci c0 n’était même pas justifié ;air-sys-syscallretiré des deps destd. Les 3 machines RE-DÉPLOYÉES depuis la source corrigée (speedy/carbon/raspi, air-sshdactive:2222, 0 glibc). Reste : rien de bloquant ; étendre check-target (paquetage des livrables) si besoin. Journal détaillé :rt/DECISIONS.md(CLA-1→CLA-4). 10 PRs #408→#417.⏱ 2026-07-24 — CHANTIER base comptes/credentials BINAIRE (ADR-100, autonome). ADR-100 ratifié Accepté (décision BDFL) — companion ADR-067/073/041. Direction BDFL : la gestion de compte Unix d’Air (comptes utilisateurs + groupes + credentials) passe en binaire sous
/etc/air/; un CLI uniqueair-accountcrée/modifie/supprime comptes et groupes ; les deux toits (libc C-ABIlibair_c+stdportée) doivent rester compatibles. Architecture :air-accountreste le médiateur unique à API gelée — seule la source lue change (binaire/etc/air/*.aircfgcanonique prioritaire, texte/etcen fallback transitoire) ⇒ libair_c (purs bindersair-libc-pwd/grp/shadow) etstd(build-std lieair_account) compatibles par construction ;/etc/passwd/group/shadowtexte deviennent des projections générées pour le monde C (ADR-041). Descellement additif couche 1 (couche-1-v2.3→v2.4, zéro rupture ABI). Plan A.0→A.6 : A.0 ADR ✅ ; A.1 crateair-account-schema✅ — schémas Cap’n Proto bespokepasswd/group/shadow(fidélité Unix : champs textuels enDataoctets ;shadow: hash Argon2id PHC secret + vieillissementOptI64=Option<i64>,-1≠ absent), enveloppeAIRCFGv1mutualisée, code généré committé (capnp 1.1.0 + capnpc-rust 0.26.0, reproductible byte-identique), 3 tests round-trip verts, fmt/clippy/machete/check-layers OK, générés exclus couverture via_capnp\.rs$. A.2 lecteur binaire dansair-account✅ (descellement additif, featurestore-binary) — modulestore(cœur pur sans-IO :parse_{passwd,group,shadow}_artifact, enveloppeAIRCFGv1+FNV réimplémentée no_std octet-compatible + décode capnp, panic-free) ;lookup_*/shadow_by_name/groups_of_userrecâblés binaire/etc/air/*.aircfgprioritaire + fallback texte (résolveurs à chemins injectables, fail-closed sur binaire corrompu) ; API publique inchangée → libair_c + std compatibles par construction. Featurestore-binaryNON-default et jamais tirée parrustc-dep-of-std(capnp interdit dans la fermeture std, ADR-090 ; la std ne lit aucun compte) ; activée explicitement par les bindersair-libc-pwd/grp/shadow(hors fermeture std) → le toit C-ABI lit le binaire. Fuzzfuzz_air_account_store3,85 M runs, 0 crash (magie découverte → décode capnp exercé). 35 tests (round-trips, login non-UTF-8,OptI64, corruption enveloppe/message, KAT FNV canonique verrouillant l’interop, deux branches résolveur, membre supplémentaire) ; store.rs 100 % lignes couvrables. A.1 (#398) et A.2 (#399) MERGÉS surmain(CI verte,test-coverageincluse). A.3 écrivain binaire + projection/etc+ import inverse ✅ — écrivain :store::seal_artifact(promu) +build_{passwd,group,shadow}_artifact(entrées → capnp scellé, tri canonique uid/gid/name, ADR-025) +write_*_binary(swap atomiqueair-filesystem::write_atomic) ; projection texte/etc(ADR-041) :shadow::encode_line(ajouté, symétrique de decode, gère-1) +codec::push_i64+project_{passwd,group,shadow}_text/_to(tri canonique, seul écrivain) ; import inverse bootstrap (/etctexte → binaire, ADR-041 §3) :import_{passwd,group,shadow}_to_binary, fuzzéfuzz_air_account_import410k/0. 48 tests (round-trips build→parse, encode→decode, import→parse, projection triée,-1projeté, écritures atomiques fichier). fmt/clippy verts (avec ET sans feature). A.3 (#400) MERGÉ surmain(CI verte). A.4 CLIair-account(couche 2, nouvelle crateair-account-cli) ✅ — DIRECTIVE BDFL déploiement : le CLI doit importer/etc/passwd+/etc/shadow+/etc/grouppour initialiser un système (cible : déployer sur speedy/carbon/raspi-srv-2). Exécutableair-account:import [--from /etc] [--to /etc/air](bootstrap : lit les 3 fichiers/etctexte → écrit/etc/air/*.aircfgbinaire par swap atomique0600— prouvé sur vrai binaire) ;user add/mod/del(mutation base binaire) ;passwd <name>(hachage Argon2id OWASP viaair-crypto, PHC → shadow ; mot de passe lu sur stdin, jamais argv). Parsing hand-rolled (pas de clap). Logique testable in-process danscli.rs/commands.rs(répertoire de base injectable, mot de passe injecté) — 15 tests (import bout-en-bout, add/mod/del, passwd+verify Argon2id, doublon/absent/corrompu refusés, parsing) ;main.rs(entrypoint I/O) exclu couverture (regex CI + barrier + COVERAGE-EXCEPTIONS prose) ; cli/commands ≥ 94 %. Additifs air-account (v2.4 à venir) :store::exists/ShadowHash::from_bytes→pub. check-layers conforme (2→1). A.4 (#401) MERGÉ surmain. A.5a CLI groups ✅ —air-account group add/mod/del <name> [--gid] [--members a,b,c](mutationgroup.aircfgbinaire, doublon/absent refusés, membres CSV filtrés) ; 17 tests, couverture 96,7 % (main exclu), cycle groupe prouvé sur vrai binaire. NOUVELLE DIRECTIVE BDFL déploiement : après A.5, packaging.debDebian/Ubuntu deux arches (amd64 speedy/carbon + arm64 raspi-srv-2, build natif par runner,dpkg-debviacargo xtask deb,/usr/sbin/air-account) puis install exécutée par l’assistant (feu vert BDFL) sur les 3 machines. A.5a (#402) MERGÉ. A.5b gate mot de passe admin ✅ — ADR-101 ratifié (mécanisme, companion ADR-073/100) : admin = root (uid 0), mot de passe admin = credential root dansshadow.aircfgvérifié en temps constant (air-crypto) ; toutes les mutations gatées (user/group add/mod/del,passwd <autre>) redemandent le mot de passe admin AVANT écriture (mismatch → refus, zéro écriture) ; gate actif ⇔ root a un hash$argon2id$(un$6$importé ou root absent/verrouillé = fenêtre de bootstrap, gate sauté → flux :importpuispasswd rootactive le gate) ;passwd= 2 secrets (admin + nouveau), lus ligne par ligne stdin (jamais argv).commands::admin_gate_active/verify_admin+cli::authorize. 21 tests (gate actif bloque/autorise, flux stdin 2 lignes, fenêtre bootstrap) ; gate prouvé sur vrai binaire (mauvais admin refusé, bon admin autorisé). Couverture 96,6 % (main exclu). PACKAGING DEBIAN — outilcargo xtask deb✅ : build release natif →DEBIAN/control+/usr/sbin/air-account(0755) →dpkg-deb(natif, pas de dep cargo) ;SOURCE_DATE_EPOCHfigé →.debreproductible (vérifié byte-identique) ; build natif par arch (amd64 speedy/carbon, arm64 raspi-srv-2 — pas de cross)..debamd64 produit sur speedy. Déploiement (install par l’assistant, feu vert BDFL) : speedy (sudo OK) + carbon (SSH OK) = amd64 ; raspi-srv-2 = SSH refusé (port 22) → build/deploy arm64 BLOQUÉ en attente que raspi soit joignable. Reste : A.6 preuve deux toits (libair_c+std) + re-sceaucouche-1-v2.4(rigueur de clôture) ; finir le déploiement (raspi arm64 quand joignable). (Leçon infra consignée : une PR sans AUCUN check = conflit de merge bloquant le dispatch CI, PAS les runners — rebaser surmain.) ⏱ 2026-07-24 (suite déploiement). ADR-102 ratifié (C-ABIair-value/air-object/air-base-capi→rlibpur + crates sœurs-chost-only, débloque le build release de la pileair-com/air-sshd, #405) +cargo xtask deb(#404,dpkg-deb,SOURCE_DATE_EPOCHdéterministe). Paquets.debproduits et installés nativement sur les 3 machines (amd64 speedy/carbon, arm64 raspi-srv-2 ;/usr/sbin/air-account), tables/etc/passwd+shadow+groupimportées en bases binaires/etc/air/*.aircfg(passwd/group0644,shadow0600).air-sshddéployé sur les 3 machines (systemdType=simple,--config /etc/air/sshd.aircfg, écoute0.0.0.0:2222en parallèle d’OpenSSH, host-key-seed aléatoire par machine,per-user+privsep on, cfg0600). A.7 lecture + JSON ✅ —air-account user list|show <name>etgroup list|show <name>(lecture seule non gatée), mode--jsonglobal (succès/lecture/erreur en JSON valide, émetteur hand-rolled sans serde, octets non-UTF-8 →{"invalid_utf8_hex":…}— zéro présomption UTF-8, Principe 3), statut de mot de passeset/none/locked(jamais le hachage, ADR-101). A.8 fix permissions ✅ — nouveauAirFileSystem::write_atomic_with_mode(couche 1,fchmoddu temporaire avant lerename→ mode final sans fenêtre, indépendant de l’umask) ;air-accountstore +importécrivent désormaispasswd/groupen0644(monde-lisible, non secrets, comme/etc/passwd) etshadowen0600(secret) — plus dechmodmanuel au déploiement. Reste : A.6 preuve deux toits (libair_c+std) + re-sceaucouche-1-v2.4.⏱ 2026-07-23 — CHANTIER ergonomie + configuration
air-ssh/air-sshd(ADR-099, autonome). ADR-099 ratifié Accepté (#382) : plan E.0→E.6 — ergonomie client (user@host, DNS, clé fichier, shell interactif) + sous-système de configuration binaire (/etc/air& XDG, édition CLI, export/import JSON). Précision BDFL normative : le JSON est exclusivement un format d’import/export pour mettre à jour les fichiers de config binaires ; à l’exécution, client et serveur lisent uniquement leur binaire. Progression : E.1user@host+ DNS ✅ (#383) —air-ssh connect [user@]host [-p PORT] [--user NAME],split_user_host,resolve_targets(IP littérale viaAirIpAddress::parse, sinonAirNameResolver::best_effortasync io_uring, IPv6 d’abord puis itération connect),$USERpar défaut ; preuveair-ssh connect user@localhost(nom résolu) → exec. E.2 clé fichier ✅ — cœur sans-IOair_ssh_proto::parse_ed25519_private_key(formatopenssh-key-v1Ed25519 non chiffré, décodage 100 % borné, refus des clés chiffrées) fuzzé (fuzz_air_ssh_openssh_key, 1,17M exec 0 crash) ; flag-i/--identity KEYFILEdansair-ssh(précédence-i>--key-seed> défaut~/.ssh/id_ed25519) ; preuve interop réelle :ssh-keygen -t ed25519→air-ssh -i <clé réelle>s’authentifie auprès d’OpenSSHsshd(.pubenauthorized_keys), 3 tests unitaires + interop verts. E.3 shell interactif ✅ — décision BDFL : descellement additif couche 1 v2.3 :AirCommand::login_terminal(slave)(un seulOwnedFdesclave PTY = terminal de contrôlesetsid+TIOCSCTTYet stdin/out/err ; se compose avecdrop_privileges_to) — équivalent unifié deSpawnAttributessetsid+ctty (C.1) sur le chemin exec. Serveur (interactive.rs) : pump PTY bidirectionnel joint (join, [ADR-098]) maître↔canal — A (canal→maître,window-change→TIOCSWINSZ,SIGHUPpar PID au teardown) ‖ B (maître→canal,exit-status+EOF+CLOSE à la sortie du shell) ; maître PTY lu viaair_async::File(IORING_OP_READ/WRITE, universels — pasRECV/SENDsockets : bug trouvé et corrigé). Client (client_shell) :pty-req+shell, mode raw termios local (RAIIRawTerminal, restauré à la sortie), stdin/stdout dupliqués (air-handle), pump viaUnordered(B « premier terminé » annule A au teardown — anodin côté client) ; nouveauSignal::SIGHUP(couche 0). Preuves réelles : pump serveur vs vraissh -ttOpenSSH (prompt bash +echo→sortie +exit+ fermeture propre) ; client+serveur de bout en bout (two_executables:air-sshsans--command→ shell interactif → marqueur). Suites vertes (air-sshd, air-process +login_terminal, air-sys-types). Correctif couverture (#386) : E.3 avait fait passer la couverture lignes sous 96 % (code interactif/démon exercé seulement par sous-processus non instrumentés) → tests in-process ajoutés (client_shell_against_air_server,serve_forever_serves_a_client_in_process, exec robuste par euid, tests unitaires binaireRawTerminal/local_terminal_io) + exclusion du binaire démon (SIGKILL’é → profil non flushable, commeair-libc-capi) ;test-coveragere-vert (96 %). 🔒 RE-SCEAU COUCHE 1couche-1-v2.3(tag GPG-signé posé surmain, clé C99E…) : fole l’additifAirCommand::login_terminal(+ constante couche 0Signal::SIGHUP) ; barrière air-process re-vérifiée verte. Couche 1 reprend son régime scellé (descellement additif v2.x, RFC ADR-015/062). 🔓 BLOCAGEcapnpLEVÉ (2026-07-23) : outil installé sur speedy —capnpC++ 1.1.0 (build source →~/.local/bin, apt=1.0.1/brew=1.5.0 ≠ pin ADR-025) + plugincapnpc-rust0.26.0 ; chaîne vérifiée reproductible (régénsshd.capnp= byte-identique). CI/build ne consomme que le runtimecapnppur-Rust (tool C++ = régén mainteneur locale, code committé). ⇒ E.4 + U.3b débloqués. E.4 config binaire serveur ✅ : (a) schéma bespokeair-sshd-config.capnp(SshdConfig: listen/hostKeys/privilegeSeparation/authorization, dansair-sshd-schema— décision B ADR-096, #388) ; (b) lecteurread_sshd_config(enveloppeAIRCFGv1+FNV-1a-64 réutilisée d’air-config-compile, parse+valide, panic-free, fuzzé 500k/0, #389) ; (c) câblage démon :air-sshd --config <artefact>litSshdConfig, sélectionne l’autorisateur (enumDaemonAuthorizer: Memory/File/Deny), multi-écoute (join_all), mode legacy conservé. U.3b magasinauthorized_keysbinaire ✅ (#390) : schémasshd-authorized-keys.capnp+parse_authorized_keys(fuzzé 500k/0) +FileAuthorizedKeys(résout home via air-account → absent=false anti-énumération, lit<home>/.config/air/sshd/authorized_keysvia air-filesystem, fail-closed). Preuve de convergence bout-en-bout (config_daemon.rs) :air-sshd --config(perUserAuthorizedKeys) + magasin binaire dans le home réel →air-sshautorisé → exec. air-ssh-config (config client) ✅ (#392) : schéma bespokessh-config.capnp(SshConfig: defaultIdentity + hostsHostBlockfaçonHostOpenSSH) +parse_ssh_config/resolve_host(fuzzé 400k/0) ;air-sshlit sa config XDG~/.config/air/ssh.aircfget résout hostName/user/port/identity par-hôte (CLI prioritaire) ; preuveair-ssh connect airtest(tout résolu depuis la config). E.5 CLIconfig get/set✅ (serveur #393, client #394) : writer d’artefactwrap_artifacten couche 2 (enveloppe AIRCFGv1+FNV-1a-64, byte-compat verrouillée par test vsverify_checksum— aucun descellement) ;write_sshd_config/write_ssh_config(round-trip write→read) ;read_sshd_config_editable(tolérant pour édition) ;air-sshd config get|set(host-key-seed/listen/privsep/authorization) +air-ssh config get|set(default-identity/host … trouve-ou-crée/host-remove). E.6 export/import JSON ✅ (serveur #395, client #396) : module JSON fait mainair_sshd::json(parseur récursif borné MAX_DEPTH=64 panic-free + sérialiseur, PAS de serde, FUZZÉ 1M/0 + round-trip stable) ;config export [--secrets](serveur élide les graines par défaut) /config import(parse JSON → valide → ré-encode binaire, jamais lu au runtime). Preuves round-trip set→export→import→binaire IDENTIQUE (config_json.rs/config_client_json.rs). 🎯 CHANTIER ADR-099 QUASI-COMPLET : E.1→E.6 + U.3b livrés ; serveurair-sshdET clientair-sshentièrement pilotés par config binaire, éditables en CLI, échangeables en JSON. RESTE : mot de passe administrateur serveur (ADR-073 :air-sshd config setdevrait l’exiger) — infra à bâtir (stockage haché type shadow + vérif), sous-chantier à part en attente de décision de périmètre BDFL.
⏱ 2026-07-23 — 🔒 RE-SCEAU COUCHE 1
couche-1-v2.2(clôture d’air-ssh, autorisation BDFL). Tag git GPG-signé posé (clé C99E…, superviseur) surmainaprès le merge de C.6 (#380). Fole les additifs couche 1 depuis v2.1 — tous additifs, aucune API scellée cassée :air-socketaccept SEQPACKET io_uring +into_owned_fdListener (#338) ;air-cryptoconstructeurs seedés X25519/Ed25519 (« F2 contributory », #356) + AEAD nonce-explicite SSH byte-exact OpenSSH (#358) ;air-processSpawnAttributessetsid+ctty (ADR-097 C.1, #369). Barrière couche 1 re-vérifiée verte. La couche 1 reprend son régime scellé (évolution par descellement additif v2.x, RFC ADR-015/062).air-uring/air-shmrestent non scellées (évolutives). air-ssh (client+serveur SSH natif Rust, sans C) est terminé et interopérable OpenSSH dans les deux sens.
⏱ 2026-07-23 — PHASE 3 air-sshd
ssh-connectionLANCÉE (autonome, ADR-097 Accepté). Arbitrages BDFL : additifs couche 1 minimaux autorisés (folés dans v2.2), shell de login/etc/passwd, périmètre serveur air-sshd complet + clientair-ssh connect(sftp/verbes config-keygen différés), multiplexing complet (sessions + port-forwarding). Plan : C.0 (ADR-097) ✅ → C.1 additif air-process (setsid+ctty) ✅ (#369, couverture 96.44 % vérifiée) → C.2 cœur canaux multiplexés ✅ (codec 14 messages RFC 4254 §9 + fenêtres défensives + ChannelTable mux + fuzz 4,3M exec) → C.3 session shell/exec ✅ (pilote serveurserve_connection: open session→pty-req/env/window-change/shell/exec ; lancement du shell de login viaair-processavec stdin/out/err sur socketpair, pump sortie→CHANNEL_DATA,exit-status+EOF+CLOSE, fermeture gracieuse RFC 4254 §5.3 drain-avant-FIN ; résolution compte via air-account ; PTY alloué sur pty-req, pump PTY interactif reporté C.5 ; preuve interop OpenSSH réellessh user@host 'echo …', 14/14 runs verts) → C.4 port-forwarding (🔨 cœur fait : moduleforward—TcpipChanneldirect/forwarded-tcpip +TcpipForwardtcpip-forward/cancel +ForwardEndpoint::port_u16; fuzzfuzz_air_ssh_forward4,8M exec 0 crash. C.4adirect-tcpip/ssh -LFAIT : split transportinto_forwarding→RecvHalf/SendHalf, pump bidirectionnel concurrent parjoin2(sansspawn/Mutex,recvjamais annulé), connect cible air-async, teardown parshutdown; preuve interop OpenSSH réellessh -L→echo relayé bout-en-bout, 6/6 runs. Rekey suspendu pendant forward (limitation assumée). ADR-098 (concurrence structurée) ratifié + IMPLÉMENTÉ : combinateursjoin/join_all/try_join+Unordered(ensemble dynamique) +Notifydans air-async (8 tests, sans unsafe) ; pump C.4a refondu surair_async::join(fin dupoll_fnbricolé). C.4btcpip-forward/ssh -RFAIT : premier moteur multiplexé d’air-sshd (tcpip_forward.rs) — écrivain sérialiséOutbox+Notify,reader_loop/writer_loop/pumps_loopjoints, ensemble dynamiqueUnordered+ astuce sentinelle, accept-loop → canauxforwarded-tcpipconcurrents, teardown propre (compteur pompes-canal, accept-loop droppé/annulé) ; preuve interop OpenSSH réellessh -R→echo relayé, 6/6 runs, teardown 0,14 s. C.4 (port-forwarding) COMPLET. C.5 démon FAIT : binaire réelair-sshd(src/bin/air-sshd.rs+ moduledaemon.rs) — socket-activation systemd (air-service) ou--listen, accept-loop concurrent (serve_forever:join(accept, driver)+Unordered+sentinelle), clé d’hôte + politique par args ; handler de bibliothèqueserve_ssh_connection(transport→auth→session) réutilisé par démon+tests ;serve_userauthrend le nom authentifié (AuthOutcome) ; privsep (--privsep) viaAirCommand::drop_privileges_to/AirStdio::Fd(drop audité couche 1) — exec unifié surAirCommand. Preuves :tests/daemon.rslance le vrai binaire — exec réel, 2 connexions simultanées (concurrence), privsep fail-closed non-root. Différés : multi-cœurspawn_on, drain SIGTERM, clé d’hôte fichier, U.3b. C.6 clientair-sshFAIT : rôle client complet (client.rs) —establish_client(KEX curve25519 + vérif signature d’hôteverify_server_kex+ cipher miroir + strict-KEX),authenticate_client(userauth publickey signée),client_exec(session + exec + collecte sortie) ; binaireair-ssh(src/bin/air-ssh.rs, verbesconnect/pubkey) ; cœurclient_kexinit. Interop double scellée en CI : air-ssh ↔ air-sshd (two_executables.rs— les DEUX exécutables Air discutent) ET air-ssh ↔ serveur OpenSSHsshd(openssh_server.rs— wire-compat client fermée). Différés : shell interactif client (PTY raw), clé OpenSSH fichier. ✅ PHASE 3 (ssh-connection) TERMINÉE — air-ssh (client+serveur) fonctionnellement complet et interopérable OpenSSH dans les deux sens.) → C.5 démon systemd → C.6 clientair-ssh connect. Couche 1 reste scellée v2.1 ; re-sceau v2.2 unique à la FIN d’air-ssh (avec F2 contributory X25519). ⏱ 2026-07-23 — CYCLE air-sshd phase 2 (autonome). ADR-094 (ssh-userauth) et ADR-095 (packaging exécutablesair-ssh) ratifiés Accepté + note d’orientation sauvegarde/restauration (/etc/air+ homes, XDG) — PR #361 mergée. U.0 (#362) + U.1 (#363) + U.2 (publickey Ed25519, #364) mergés. U.3a (autorisation + events AirCom) FAIT : traitAuthorizedKeys(backendsInMemoryAuthorizedKeys/DenyAll) consulté parserve_userauth(SUCCESS exige signature valide ET clé autorisée) + eventsAuthOk/AuthFailedpubliés sur AirCom (schéma déjà figé) ; preuves : clé non autorisée→FAILURE, publication AuthFailed→AuthOk via subscriber in-process, interop OpenSSH. U.3b (magasinauthorized_keysbinaire fichier) : archi TRANCHÉE (option B, ADR-096 Proposé) = schéma bespokeair-sshd-schema+ lecteur dédié couche-clean (read_to_bytes+verify_checksum, PAS de mmap couche-0) implémentant le traitAuthorizedKeysde U.3a ; résolution home via air-account. Implémentation encore BLOQUÉE : outilcapnpABSENT (génération du code du schéma = op mainteneur). Couche 1 reste SCELLÉE (v2.1) jusqu’à la fin d’air-ssh (décision BDFL 2026-07-23) ; re-sceaucouche-1-v2.2unique alors (F2 contributory X25519 + additifs). U.4 (durcissements + preuve CI succès/échec) FAIT : essais bornés (max_failures, défaut 6 comme OpenSSH), anti-énumération (réponse d’échec uniforme, testée byte-exact), grace-time documentée (envelopperuntime.timeoutcôté pilote) ; preuve d’interop scellée : un vraissh -i(cléssh-keygenautorisée) → OpenSSH « Authenticated to … using publickey » + les cas d’échec (DenyAll) conservés. U.2 (publickey Ed25519) FAIT : cœurverify_publickey_signature(reconstruction donnée signée RFC 4252 §7 + vérif constant-timeair-cryptosursession_id) + pilote (serve_userauth: sonde→PK_OK, signature valide→USERAUTH_SUCCESS) ; preuves cœur (signature valide/altérée/rejeu/malformée) + e2e in-process (SUCCESS/PK_OK/rejeu) + interop OpenSSH + fuzz 3,8M exec 0 crash. U.1 (codec + StateMachinessh-userauth) FAIT : cœurair-ssh-proto::userauth(codecUserauthRequestdecode/encode borné anti-hostile, réponsesFAILURE/SUCCESS/BANNER/PK_OK, bootstrapSERVICE_REQUEST/ACCEPT,UserauthStateMachine) + piloteserve_userauth(câblageSERVICE_REQUEST→userauth,none→FAILURE publickey) ; preuves : cœur ~100 % (unit + proptest model + round-trip), e2e in-process (4 cas : none/erreurs/disconnect), interop OpenSSH réel viaserve_userauth, fuzzfuzz_air_ssh_userauth6,4M exec 0 crash. Prochain : U.2 (publickey Ed25519 : sondePK_OK+ vérif de signature sursession_id). Dernière mise à jour : 2026-08-30 (campagne d’assainissement des 29–30 août —test-airvert sur la cible pour la première fois après trois obstacles distincts ; les deux cliquets de couverture relevés,workspace76,14 → 80,87 etlayer274,79 → 76,56 ; le défaut de cadence de la mesure, décrit trois fois et jamais corrigé, fermé par l’audit hebdomadaire ; ADR-106 amendé — Air refuse le C, pas Unix ; la detteos::unixramenée à deux sites légitimes ;air-launchddégelé, 3b reprend à son incrément 2. Détail par PR dansJOURNAL.md.)
0. Actualité — CIBLE DE PRODUCTION (2026-07-10)
🎯 OBJECTIF PREMIER (pivot 2026-07-10, ADR-088) : une toolchain Rust dont la
stdrepose uniquement sur Air, par un PAL custom safe bindant la couche 1, SANS libc C. Sans cet outil fondamental, on ne peut pas produire sereinement le reste. Le jalon M5 est ATTEINT mais par le chemin de l’option A (ADR-076) :stdliait la libc Clibair_c— or la frontière C réintroduit de l’unsafe, incohérent avec la doctrine («unsafeen couche 0 seulement »). ADR-088 amende ADR-076 : PAL customstd::sys::pal::airbindant les Managers couche 1 Rust safe (premier backendstdsafe de bout en bout), cibletarget_os="air",libair_csurvit mais découplée destd,AirTaskManagerremplaceAirProcessManager, résorberaw_syscall. M5 = marchepied de faisabilité. Étude :etude-std-pal-air-safe-fr.md.Cible produit ensuite :
air-sshd— le serveur SSH d’Air, full Rust, async io_uring, wire-compat OpenSSH (ADR-074) — la toolchain safe est son prérequis. (Il n’y a pas de jalon « compiler OpenSSH réel » : OpenSSH n’était qu’une fonction de forçage, rôle rempli.)
Acquis récents (couche 1 scellée → toits) :
- 🎉 M5 ATTEINT (2026-07-10) —
stdtourne sur Air, ZÉRO glibc, 2 arches. Le lien dehello-stdcontrelibairpasse de 12 → 0 symboles indéfinis (5 PR :__xpg_strerror_r#308,open/fcntl#309, introspection thread couche 1 [ADR-086] #310,getauxval/pthread_self/détection-pile #311, TLS keys#[thread_local]#312,syscallgénérique [ADR-087] #313). Run on-target :hello from std on air+ exit 0 sur x86_64-air (carbon) ET aarch64-air (raspi). Un programme Ruststdstatic-PIE lié intégralement sans glibc. Pari ADR-076 tenu de bout en bout. Re-sceauxcouche-0-v1.12(ADR-087raw_syscall, escape hatch temporaire à retirer avec l’équipe Rust) +couche-1-v1.9(ADR-086 introspection thread). - Campagne réseau ADR-069 close : registre de handles
air-handle, moteur socketair-socket, surface socket libc complète (30 symboles,libair_c.so) +sendmsg/recvmsg/SCM_RIGHTS. Re-sceauxcouche-0-v1.10(ADR-070 sockaddr + ADR-071 sockopt) etcouche-1-v1.6(ADR-069). CI basculée x86 (ADR-072). - Doctrines gravées : config binaire (ADR-073),
vision
air-sshd(ADR-074) — la vraie cible (OpenSSH n’était qu’une fonction de forçage), fourniture du PAL (ADR-075). - Chantier A du PAL COMPLET : 6 briques additives couche 1 (
AirCondvar,AirOnce, os_str, mutation d’env,yield_now/set_thread_name, destructeursthread_local). - Bug de fondation aarch64 CORRIGÉ (gdb) : le TCB (64 o) recouvrait la zone TLS
(
tpidr_el0base commune TCB+TLS à+16) → tout 2ᵉ#[thread_local]cassait. Fix validé 2 arches (selftestrtx86-air = 42 ET aarch64-air = 42). - Chantier B lancé, option A prouvée :
build-std=stdcompile entièrement pour Air (env=musl, zéro patch libc/std) ; reste le lien (-lc= libair,-lunwind= stub).
⚠️ 2026-07-08 — RÈGLE + ARCHITECTURE (ADR-077) : la libc bind EXCLUSIVEMENT la couche 1 (jamais la couche 0), via des Managers de domaine objet (
AirFileManager/AirTaskManager/AirNetworkManager/AirSystemManager/AirSignalManager/AirEnvironmentManager/AirMemoryManager). Remédiation livrée (re-sceaucouche-1-v1.7, ADR-077) : les Managers sont la surface médiatrice objet consommée par les toits (libc et PAL), jamais la couche 0 en direct. Les symbolesair-libc-*bindent les Managers / briques couche 1.
Prochaines tâches (pivot ADR-088 — chantier PAL safe). Le jalon M5 (option A : std sur
la libc C libair_c) est atteint et conservé comme marchepied de faisabilité, mais il
réintroduit de l’unsafe par la frontière C — incohérent avec la doctrine. ADR-088
pivote : std reposera sur un PAL custom safe (std::sys::pal::air + sys/<domaine>/air.rs)
bindant les Managers couche 1 Rust safe, zéro FFI, cible target_os="air" (Air n’est
plus déguisé en unix), libair_c découplée de std, escape hatch raw_syscall résorbé.
Stratégie de sceau : desceller la couche 1 une fois pour tout le chantier, resceller une fois
au jalon (comme couche-1-v1.0).
- ✅ Étapes 1–2 faites : note d’étude (
etude-std-pal-air-safe-fr.md)- ADR-088 acté (tranche §6.a–e). ADR-089 réserve le
SecurityManager(#324).
- ADR-088 acté (tranche §6.a–e). ADR-089 réserve le
- ✅ Lot 1 mergé (#322/#323) — seul changement cassant : renommage
AirProcessManager → AirTaskManager(médieair-process/air-thread/air-runtime+resource_limit) + surfacefutex/gettid; propagation doc (macro-archi, ADR-077/085/086, INDEX, registre, libc-conformance). - ✅ Étape 3 faite (incrément 0+1, branche
feat/pal-safe-air-target, en revue) — reprofilage cible : cible canonique*-linux-airreprofiléetarget_os="air"(retraitenv=musl/target-family), option-A renommée marchepied*-linux-air-musl(M5 préservé, baseline rejouée :hello from std on air/exit 0/zéro glibc). Forkrust-srclocalisé réversible (rt/rust-src-air/: patch +apply-air-pal.sh+ copievendor/gitignorée, override__CARGO_TESTS_ONLY_SRC_ROOT).stdCOMPILE pour*-linux-air, 2 arches (x86_64 + aarch64), lien d’un binairestdminimal sans symbole libc indéfini (patch minimal : 5 fichiersstd::sys, murs A1–A4).os=airinconnue ⇒restricted_std(voie standard des ports custom). - ✅ Incrément 2 — PROTOTYPE
rustc-dep-of-stdsurair-alloc: PROUVÉ + dé-risqué (commit5f12621, branche).stdcompile 2 arches avec le vraiair-alloc(arènemmap/futexcouche 0) comme allocateur global — sondeair-alloc-probe: ELF static-pie, zéro glibc, zéro symbole indéfini, 5 Kio (arène réelle liée, plus le placeholder). Mesure décisive : la couche 0/1 ne change QU’EN build-config (3Cargo.toml, +38 l., featurerustc-dep-of-std+ façadesrustc-std-workspace-*) — ZÉRO.rssource, zéro sémantique, zéro gate/scellé touché. Vérifié superviseur : tests hôte des 3 crates scellées verts (23+807+199, 0 échec). ⇒ Le descellement formel de la couche 0 N’EST PAS nécessaire (crates Air déjàno_std-propres). - ✅ Incrément 3 — AUDIT deps externes (commit
cfe216d, branche) : fardeau brut de binding naïf = ~71 forks (33 icu4x viaair-base-lib+ 37 RustCrypto viaair-crypto+ 1bitflags), intenable — MAIS presque entièrement évitable par DÉCOUPLAGE, pas par fork. Aucune externe n’arustc-dep-of-stdamont (toutesno_std) ; les 11 proc-macros compilent pour l’hôte → zéro fork. Trouvaille : les 2 grappes 🔴 n’entrent dansstdque par couplage accidentel — (1)AirError/AirResult/AirPathsont définis dansair-base-core(icu-free) ;air-base-libne fait que les ré-exporter + ajouter icu ⇒ rediriger les imports Managers fs/net/process/runtime versair-base-coresort icu4x du graphestd(chemin d’import, zéro sémantique) ; (2)AirRandom::filln’appelle queair-sys-syscall::getrandom(couche 0) ⇒ isoler le random sort RustCrypto du graphe. Vérifié source :air-base-coredéfinit bien les types, Vague 0 dépend deair-base-core(pas-lib). - ✅ ADR-090 ratifié (2026-07-11, sur
main) — grave la discipline : binding parrustc-dep-of-std(build-config, pas de descellement) ; importsair-base-corejamaisair-base-lib; exceptions 80 % (icu4x/RustCrypto) hors fermeturestd;AirRandomhors crypto ; frontièreno_std= relation àstd; industrialisation par vagues. - ✅ Vague 0 — binding réel du noyau (commit
a9769c4, branche) :stdcompile ET lie pour x86_64 ET aarch64 sur 5 vrais Managers —io/error→air-base-core,pal/air::futex+sync/*(Mutex/Condvar/RwLock/Once/Parkerréels)→air-thread::raw_futex,stdio→air-stdio,env→air-env(+alloc→air-alloc). Fermeture vérifiée : uniquementbitflags(ni icu4x, ni RustCrypto, niair-base-lib) — fork count = 1. Zéro.rscouche 0/1 touché, hôte scellé vert. Lien : static-pie zéro glibc ; x86_64 =__tls_get_addrseul indéfini (hook TLS runtime), aarch64 = zéro indéfini. Placeholders restants :random,thread_local. - ✅ Vague 1a — découplage
air-base-lib→air-base-core(commitsb9b4edc/89ba1d4/9d80380, branche) : seulsair-filesystem+air-socketdépendaient directement d’air-base-lib(les 3 autres déjà surair-base-core). Redirection ABI-neutre, aucun cas d’i18n réel (le seulfrom_utf8_lossy= wrapper stdlib zéro-icu). Vérifié :air-filesystem/air-socket/air-processont icu4x=0, air-base-lib=0 dans leur fermeture (fork=1).exitréel bindé surair-process::exit_process(prouvé au désassemblage :__NR_exit_group, pasabort()). Hôte scellé vert (aucune régression). - 🎯 Vague 1b — JALON ATTEINT (commits
396944c/636eda7/168dd4b, branche) :hello-stds’exécute sur le PAL safe. Résidus Vague 0 levés : (1)__tls_get_addrsupprimé via"tls-model": "local-exec"dans les specs cibles (TLS statique, accès%fs:OFFSETdirect) ; (2)_startabouté au bootstrap d’air-runtime(crt0 Air : self-reloc/TLS/TCB/canari/argv →__air_start→bootstrap→main→println!→exit_process),_starthors de std (côté binaire, ne casse pas les#![no_main]).air-runtime+air-memoryenrustc-dep-of-std(build-config, zéro source couche 1 touchée), fermeture sans icu4x/crypto, fork=1. VÉRIFIÉ SUPERVISEUR sur 2 arches :hello from std on air+exit 0 sur x86_64/carbon ET aarch64/raspi-srv-2 (sha256 identique, static-pie, 0 glibc, 0 UND). Hôte scellé vert (127 tests feature OFF), zéro FFI hors_startasm/extern "C"(SAFETY documenté), aucun descellement. - ✅ Bug spawn aarch64 : NON REPRODUCTIBLE (2026-07-11, raspi) —
air_runtime::thread::spawn(clone3+CLONE_SETTLS Variant I) prouvé sound (~16 000 spawns : canonique.tbss+ instrumenté.tdata+ isolation ; selftest rt=42, 5/5 re-vérif superviseur). Les 2 bugs historiques sont corrigés (TCB/TLS overlap 2026-07-07 ; asmx19→x21#150). Le TODO « fix bug spawn aarch64 » était stale ⇒sys/thread(PAL) est débloqué côté runtime. - ✅ Vague 1c —
sys/threadréel (commits2e953ef/2ccb15e, branche) :std::thread::spawn+joins’exécutent sur le PAL safe, 2 arches VÉRIFIÉES superviseur (x86_64/carbon + aarch64/raspi : workersum=5050, join, exit 0 ; stress 800 threads OK). BindingThread::new→air_runtime::thread::spawn(trampolineextern "C" fn(usize),Box<ThreadInit>pointeur fin,expose_provenance),join→futexCHILD_CLEARTID,set_name/yield_now→air-thread,sleep→air-base-core, id→current_tid;available_parallelism/garde de pile = stubs documentés. Zéro source couche 0/1 touchée, fermeture toujours 1 fork,air-runtimedéjà bindé (rien à ajouter). - ✅
air-allocalignement fort — MERGÉ surmain(#325,384c80b, 2026-07-11) : implémenté l’alignement fort (align > 16,memaligndlmalloc) dans l’arène, lève ADR-056 D5 (amendé). Découvert viastd::sync::mpsc(align 64 → l’ancien refusalign>16→nullfaisait abort SIGILL). 30 tests (matrice 32/64/128/256/page +realloc/dedicated/alloc_zeroed/OOM) + property + fuzz (44 600 exécutions). CI verte (test-coverage root ✓, supply-chain ✓, cross-aarch64 ✓). ⇒mpsc/ crossbeam/types sur-alignés débloqués (la branche PAL rebasera sur cemainpour en bénéficier). - ✅
std::sync::mpscVALIDÉ sur le PAL, 2 arches (branche, merge927e624+ sonded85405e) : après intégration du fixair-alloc(mergemain, conflit nul), la sondehello-mpsc(8 producteurs × 1000, somme32004000) s’exécute — x86_64/carbon (120 runs 0 échec) ET aarch64/raspi (vrai ARM), exit 0, static-pie 0 glibc. Le cas SIGILL (V1c-6) est clos, fix align-fort prouvé en contexte.air-alloc= dépendancepathdestd(rustc-dep-of-std) ⇒ patch rust-src inchangé. - ✅
sys/fd+ minimalsys/fsréels (commitsd32fb79/f05907c, branche) :FileDesc(OwnedFd) →AirFileManager(read/write/read_at/write_at/seek/fsync/ftruncate/set_nonblocking) +duplicate→air-handle;File::open/create/read/write/seekminimal. Aller-retour fichierstdprouvé 2 arches (sondehello-fs: 16 Ko écrits+relus identiques + seek, exit 0 ; x86_64/carbon 10/10 + aarch64/raspi). Zéro additif couche 1, zérounsafedans le bras, zéro source couche 0/1, fermeture 1 fork. Frontière assumée (Vfd-3) :std::os::fdnon exposé (éviterait un portagenet/pipe/stdio-fd complet) — unificationair-handle(ADR-069) = chantier distinct. - ✅
sys/fsCOMPLÉTÉ (commitsc2879d5/9aa5290, branche) : métadonnées (statx→FileAttr/FileType/FilePermissions, temps),ReadDir/DirEntry(getdents64),unlink/rename/mkdir/rmdir/DirBuilder/remove_dir_all, liens (symlink/link/readlink/canonicalize/copy),set_permissions/set_times,exists;current_exe(readlinkat /proc/self/exe, brassys/paths/air.rs). Zéro additif couche 1, fermeture propre (fork=1, icu/crypto/regex=0). Prouvé 2 arches (sondehello-fs2:read_dirtrié + métadonnées + liens + rename + suppression +current_exe, exit 0 ; x86_64/carbon + aarch64/raspi — 1ʳᵉ panique aarch64 = artefact de renommage du binaire par le superviseur, re-run au bon nom = OK).std::fslargement fonctionnel. - ✅
sys/processréel (commits76a8dc1/f372a9c, branche) :std::process::Commandspawn/exec/wait/status/kill — bâti surAirProcess(pidfd :clone3+CLONE_PIDFD/execve/waitid/pidfd_send_signal, wait+kill en couche 1, passpawn_processnu qui exigerait unwaitidcouche 0).StdioInherit/Null/Fd;Piped/outputdifférés (avecstd::os::fd/pipe). Prouvé 2 arches (hello-proc:/bin/true→0,/bin/false→1, introuvable→127, exit 0 ; x86_64/carbon 20/20 + aarch64/raspi). Zéro additif couche 1, zéro FFI, zérounsafe, fermeture 1 fork (air-account→air-base-core, découplage tenu). - ✅
sys/netréel (commits54a53f6/ce6e8d7, branche) :std::netTCP + UDP — bâti surair_socket::engine::AirSocket(couche 1&self, pas les façades&mut self).TcpStreamconnect/read/write/peek/shutdown/nodelay/keepalive/nonblocking/addrs ;TcpListenerbind/accept (SO_REUSEADDR, backlog 128) ;UdpSocketsend_to/recv_from/peek_from + connect/send/recv ; adresses numériques (DNS différé). Prouvé 2 arches (hello-net: TCP + UDP loopback roundtrip, exit 0 ; x86_64/carbon 10/10 + aarch64/raspi). Zéro additif fonctionnel couche 1, fermeture 1 fork. - ✅
stdFONCTIONNELLEMENT COMPLET pour fichiers / threads / mpsc / process / réseau — le réseau était le dernier gros sous-système. Reste pour « std bouclé » :sys/random(dernier mur dur), + différés soft. - 🎉
sys/randomréel —stdFONCTIONNELLEMENT BOUCLÉ (commits82517f1/12b676e/6e336c0, branche) : isolation par micro-crateair-random(couche 1,no_std, surgetrandomcouche 0) —air-cryptola ré-exporte (surfacefill/generate_keyinchangée, régression hôte 0 : air-crypto 42/42, air-random 3/3). Gate ADR-090 vérifié superviseur : fermetureair-random={air-sys-syscall, air-sys-types, bitflags}, RustCrypto = 0 (build-std 33 crates : RustCrypto/icu4x/air-base-lib = 0, fork = 1).sys/random/air.rs(zérounsafe) →HashMap/RandomStateamorcés sur CSPRNG kernel. Prouvé 2 arches (hello-random: entropie réelle distincte +HashMap1000 entrées, exit 0 ; x86_64/carbon 5/5 + aarch64/raspi). ⇒ tous les murs durs destdfranchis. - ✅ Portage
stdMERGÉ surmain(#326,ce46a0b, 2026-07-11) : squash, CI verte (test-coverage root + supply-chain + build + loom + cross-aarch64), Verified GitHub. Couche 1 évoluée (découplageair-filesystem/air-socket→air-base-core, micro-crateair-random, featuresrustc-dep-of-std) mais NON re-scellée — le re-sceau viendra après les différés soft (décision BDFL : ne desceller qu’une fois). Forkrust-srclocalisé (rt/rust-src-air,vendor/gitignoré). - ⏭️ Séquence en cours (BDFL, branche
feat/std-osfd-pipe, 1 PR à la fin) : différés soft PUIS re-sceau. (a) ✅std::os::fd+sys/pipe(commit3689ebb) —AsRawFd/OwnedFd,io::pipe,Stdio::Piped/Command::outputdébloqués ; zéro additif couche 1 (viasys/pipe+AirStdio::Fd), fermeture 1 fork ; prouvé 2 arches (hello-pipe:io::pipe+ capture"hello-air\n"+os::fd, exit 0). (b) ✅ options socket (commit4105188) — additif couche 1air-socket(méthodes typées&self, +5 tests, zérounsafe) : ttl/linger/nodelay+keepalive getters/broadcast/multicast loop+ttl/only_v6/connect_timeout (ppoll+SO_ERROR)/duplicate ; prouvé 2 arches (hello-sockoptset==get, exit 0), fermeture 1 fork, régression hôte 0. Options à STRUCTURE — additif COUCHE 0 v1.13 FAIT (commits27195dd/1f89c98) :air-sys-types(ip_mreq/ipv6_mreq#[repr(C)],const assert!ABI) +air-sys-syscall(6 wrappers dédiés typés) + couche 1air-socket+ PAL ⇒read_timeout/write_timeout(+ read vide expiré) + multicast join/leave v4/v6, prouvés 2 arches. Plus aucununsupportedsur les options socket. ⚠️ Barrière : plancher lignes rouge (93.29%<94) = artefactllvm-covd’hôte (net.rs 64.53% au baseline = impossible pour couche 0 scellée ; branches +12/0-manque) → arbitre = CItest-coverageroot ; tagcouche-0-v1.13contingent à CI verte. (c) ✅ DNS FAIT (commits0ec7032/e18117c) — câblagelookup_hostsurAirNameResolverexistant (best_effort[hosts, DNS RFC 1035, localhost], IPv6-first) + additifair-socket(AirLocalhostSource, pontscore::net, zérounsafe) ; prouvé 2 arches (hello-dns: localhost + numérique + connect-par-nom, exit 0), fermeture 1 fork (résolveur maison,getaddrinfo0). ⇒ TOUS les différés soft traités.hostname()laisséunsupported(tireraitair-system— chantier distinct). Reste : PR unique + CI (arbitre couverture couche 0) + re-sceaux (couche-0-v1.13 + couche-1). - 📌 DIRECTIVE BDFL (2026-07-11, après consolidation) : rendre normatif le motif réseau sans-IO
(
docs/notes/reseau-architecture-crates-fr.md) via un ADR — toute impl réseau doit s’y conformer (seul motif testant la pile indépendamment). Précurseur :AirDnsSource. À faire avantair-async/ couche 2. Voir mémoire projet. - 🗺️ PHASE SUIVANTE = couche 2 (séquence BDFL) : runloop
air-async(réacteur io_uring↔Waker/ exécuteur/timers) → IPC Air → framework réseau Air →air-sshd(démonstration couche 2). En parallèle, à traiter : hygiène pré-merge branche PAL (Cargo.lockstubs façades →deny/audit/machete+ repro), merge de la branche PAL, re-sceau couche 1 (ADR-088 « resceller une fois »). Différés softstd(non bloquants pour un programme courant) :std::os::fd(AsRawFd/FromRawFd) +sys/pipe(io::pipe, chantier libc-fd, débloqueStdio::Piped/Command::output) ; options socket fines (timeouts/ttl/linger/broadcast/multicast) ; DNS (brancherAirNameResolversurlookup_host) ;available_parallelism/park-unpark/TLS dynamique. RFC amont (etude-rfc-trait-backend-std-fr.md). - 🗺️ Roadmap au-delà du portage
std(séquence BDFL 2026-07-11) : (1) bouclerstd(fs/process/net) ; (2) couche 2 — la runloop (=air-async, réacteur io_uring↔Waker/exécuteur/timers, pièce maîtresse identifiée dansarchitecture-prerequis-air-sshd-fr.md), puis l’IPC Air, puis le framework réseau Air ; (3)air-sshd(ADR-074) — composant réseau primordial et démonstration du fonctionnement de la couche 2. Puis hygiène pré-merge (Cargo.lockstubs façades →deny/audit/machete+ repro ADR-025), merge de la branche, re-sceau couche 1 (ADR-088 « desceller une fois, resceller une fois »). RFC amont Tier-3 + backendstd::syssafe (etude-rfc-trait-backend-std-fr.md) : la preuve downstream est acquise. - ⏭️ Étape 4 (conditionnée à la décision) — implémentation par sous-système (facile→dur, chacun bindant son Manager) :
exit/random/time/os_str/args/env→fd/stdio→futex+sync(les 3 wrappers débloquentMutex/Condvar/RwLock/Once/Parker) →fs→thread+thread_local→process→net. Manques couche 1 (§5 étude :current_exe,DirEntrytypé,park/unpark, options socket) comblés en additifsv1.xau fil de l’eau. - 🎯 Jalon de succès :
hello-stdre-tourne sur le PAL safe, 2 arches, sanslibair_cliée — preuve d’un userland Rust safe de bout en bout (unsafeconfiné couche 0). Puis re-sceau couche 1 unique + RFC amont (Tier-3*-linux-air, backendstd::syssafe).
Dettes/leçons associées : raw_syscall/GlobalAlloc-safe traités dans ce chantier (§5) ;
lane CI nightly hello-std/rt à monter ; leçon gravée jamais différer les runs raspi (un
cross-build aarch64 vert ne prouve rien au runtime). Cible produit après la toolchain safe :
air-sshd (ADR-074).
1. Où en est-on (vue d’ensemble)
| Couche | État | Détail |
|---|---|---|
Couche 0 (air-sys-types + air-sys-syscall) | 🔒 SCELLÉE couche-0-v1.11 (ADR-066 v1.9 signaux async + ADR-070/071 v1.10 sockaddr/sockopt libc, campagne réseau ; ADR-085 v1.11 le 2026-07-09 — additifs cumulés libc/std : chroot, kill_process_group→killpg, sigaltstack+AltStack, sendfile, fchmod/fchown/futimens, munmap_raw ; pause sans additif via ppoll ; ADR-087 v1.12 le 2026-07-10 — raw_syscall, escape hatch syscall générique nécessaire au pal std, EXCEPTION assumée temporaire à retirer avec l’équipe Rust, jalon M5) | Code complet (11 familles + io_uring 12 Temps + termios/tty). N’évolue plus que par descellement additif (RFC, ADR-015/ADR-051). Additif v1.9 (ADR-066) : sous-module async_handler (rt_sigaction non-faute = délivrance async réelle, install/restore) + trampoline rt_sigreturn x86_64 (global_asm) + rt_sigpending ; les 4 fautes restent inertes (ADR-064). Tag posé par le superviseur après barrière x86 + CI aarch64. Couverture 100 % hors exceptions documentées, 2 arches. |
| Couche 1 (briques Rust : libc + PAL) | 🔒 SCELLÉE couche-1-v1.0 → re-scellée jusqu’à couche-1-v1.9 (v1.1 errno_location ADR-065 ; v1.2 signaux/futex ADR-066 ; v1.3 air-account ADR-067 ; v1.4/v1.5 credentials+drop_to_user ADR-068 ; v1.6 campagne réseau air-handle+moteur socket ADR-069 ; v1.7 le 2026-07-09 — Managers de domaine (ADR-077) : AirFileManager/AirProcessManager/AirEnvironmentManager/AirMemoryManager/AirSystemManager/AirNetworkManager + nouveau AirSignalManager (set_alt_stack/pause), surface médiatrice objet consommée par la libc, chantier B ; v1.8 le 2026-07-09 — air-process::spawn_process (fork+file-actions+exec async-signal-safe) + types SpawnFileAction/SpawnAttributes, socle de la face libc posix_spawn, chantier B ; v1.9 le 2026-07-10 — introspection thread cible (ADR-086) : air-runtime::process_context [auxv + bornes de pile] + AirProcessManager::resource_limit, socle de getauxval/pthread_getattr_np de la libc, jalon M5 ; earmark ADR-078 crypto air-tls → couche-1-v1.10) | API Rust publique figée (17 crates, voir ADR-062). Évolue par descellement additif v1.x (RFC ; v1.1 = AirRuntime::errno_location, ADR-065 ; v1.2 = air-signal::install_handler/restore_disposition/pending — brique d’install de handler non-faute dual-face — + air-thread::raw_futex — primitives futex(2) typées sur mémoire externe, ADR-066). v1.3 = crate air-account (codecs comptes /etc/passwd+/etc/group, brique bindée par la libc, ADR-067). Tag superviseur après validation couche 0. Les 4 P0 + remodel env/args pré-sceau. |
| Toit libc C-ABI (scopée OpenSSH) | 🔨 En production (air-libc-capi, roadmap roadmap-libc-openssh-fr.md) | M0/M1-a/M1-b/M1-c/M1-d faits (crt+errno ; str*/mem* ; malloc+moteur format fuzzé ; FILE*+exit/atexit/getenv ; frontière variadique printf). M2 implémenté (à valider superviseur) : I/O fichier fd brut + métadonnées (struct stat ABI Air) + répertoires (DIR opaque) + mkstemp/realpath/getcwd — crate air-libc-fileio (logique mesurée) + shims air-libc-capi::fileio + shim C variadique fileio_shim.c (open/openat/fcntl). Archi libc : logique mesurée / shims C-ABI exclus — 6 crates de logique (air-libc-{fmt,alloc,stdio,stdlib,printf,fileio}) + shims air-libc-capi (cf. docs/libc-conformance.md). M3 Phase A faite (fondations couches 0/1, ADR-066 : install async non-faute + raw_futex). M3 Phase B VALIDÉE x86 (barrière verte : lignes 96.4 %, branches 82.9 %) : familles signaux (sigaction/signal/kill/raise/sigprocmask/sigsuspend/sigset* — crate air-libc-signal, 4 fautes INERTES ADR-064) et pthread (mutex/cond/rwlock/once/key/create/join/… sur air-thread::raw_futex — crate air-libc-thread, cycle de vie/TLS hôte). Types opaques ABI Air (mutex 40/cond 48/rwlock 56, sigset_t 128) + trailer cbindgen hand-written. Preuve d’exécution C (air-libc-c/tests/m3/) : mutex end-to-end + sigaction/raise handler async + déréf NULL → SIGSEGV coredump inerte. pthread_create NON testable via C -lair_c sur l’hôte (récursion d’interposition std::thread→pthread_create ; sans glibc/on-target = OK) — couvert par tests unitaires air-libc-thread. Couverture : air-libc-c (artefact cdylib pur) EXCLU des runs llvm-cov (barrier + CI) — son test --lib force-lierait les shims pthread/TLS surchargeant la glibc (SIGSEGV) ; air-libc-capi modules signal/thread gatés #[cfg(not(test))]. Déviations D-M3.1..D-M3.9 ; additifs remontés (pthread_exit propre, dtor TLS cible, rt_sigsuspend atomique). M4.2 Temps VALIDÉE x86 : clock_gettime (REALTIME+MONOTONIC), gettimeofday, time — crate air-libc-time (logique mesurée, sur air-base-core couche 1, ZÉRO additif : monotone brut reconstruit via AirInstant::duration_since(zéro)). Types timeval/time_t/clockid_t ; preuve d’exécution C (tests/m4/time.c). Additifs remontés M4 : nanosleep (sommeil non-retry couche 1 — air-base-core::sleep retry-EINTR ≠ POSIX), clock_getres, strftime/localtime_r (tz icu4x). M4 Comptes VALIDÉE x86 : getpwnam_r/getpwuid_r (réentrants) — crate air-libc-pwd (parseur /etc/passwd mesuré + empaquetage borné anti-overflow, lecture via air-filesystem::read_to_bytes couche 1) ; struct passwd/uid_t/gid_t ; convention *_r (errno rendu direct, ERANGE/absent) ; preuve C (tests/m4/pwd.c contre le vrai /etc/passwd). Refactor ADR-067 : air-libc-pwd re-câblé en pur binder d’air-account (parseur retiré, ABI inchangée) ; groupes getgrnam_r/getgrgid_r (crate air-libc-grp, packing gr_mem aligné/borné). Non-réentrants + shadow = tranches suivantes. shadow (getspnam_r, codec durci ShadowHash zeroïsé) et identifiants (ADR-068 : air-process::credentials + binders getuid/setuid/setresuid/setgroups/initgroups, sémantique POSIX setuid ratifiée) faits. A.6 air-process::drop_to_user VALIDÉE x86+aarch64 (boucle A close 6/6) : résolveur AirPrivilegeDrop::for_user (résout un nom via air-account → descripteur de largage enrichissable : uid/gid primaires + groupes supplémentaires réels, sémantique initgroups) + convenance drop_to_user (résout puis drop_privileges) ; ADR-068 ; PAS de binder libc (OpenSSH largue via les syscalls individuels déjà exposés) ; re-sceau additif couche-1-v1.5. |
| Couches 2+ | ⏳ Non commencées | Toits (libc C-ABI, PAL std::sys) construits au-dessus de la couche 1 scellée. |
Couche 1 scellée le 2026-07-03 (tag couche-1-v1.0, ADR-062). Voir §4 pour les jalons.
2. Sceau couche-1 : FAIT — et file des tâches à venir
Décision de gouvernance BDFL : combler les P0 des audits, remodeler ce qui l’exigeait, PUIS sceller. Tout est fait :
| # | Tâche | Statut |
|---|---|---|
| P0.1 | AirFile — handle I/O fichier générique (air-filesystem) | ✅ Mergé (#214) |
| P0.2 | air-poll — multiplexeur ppoll générique (crate dédiée) | ✅ Mergé (#215) |
| P0.3 | termios/PTY/job-control re-exposé (air-terminal) | ✅ Mergé (#216) |
| P0.4 | air-signal — face signal générique, 100 % safe (ADR-064) | ✅ Mergé (#217) |
| Remodel | os_str — AirOsStr/AirOsString, env/args figés (sceau-readiness) | ✅ Mergé (#218) |
| §5.4 | Sceau couche-1-v1.0 — ADR-062 + tag git | ✅ SCELLÉ 2026-07-03 |
Prochaine étape (voir §0 Actualité en tête) : air-sshd — le serveur SSH d’Air full Rust (ADR-074). Le chantier B du PAL est clos : std lie et tourne sur la libc d’Air (M5 atteint, hello-std 2 arches). Toits libc C-ABI et PAL prouvés ; la toolchain Rust full-Air est acquise et sert désormais de socle à la production air-sshd.
Additifs v1.x (descellement additif, non bloquants) : spawn ergonomique §2.B.2,
Condvar/Once, set_var/mutation d’env, destructeurs TLS, getsockopt/setsockopt,
mman fichier-adossé, stat/fstat public, mkstemp, barrière de poison + fault_state()
(ADR-064 §7), gap aarch64 crypto (#14), instrumentation IO debug (ADR-063), regex
(§4-I), AirRuntime::errno_location() -> *mut i32 (socle de __errno_location() de la
libc — premier additif révélé par le toit libc M0, roadmap §6 ; RFC de descellement
additif requis), M3 Phase A — signaux+pthread (ADR-066) : install de handler async
réel pour les signaux non-faute (air-signal::install_handler/restore_disposition,
unsafe localisé) + pending(), et primitives futex(2) typées sur mémoire externe
(air-thread::raw_futex) pour bâtir pthread_mutex_t/cond_t de la libc et std::sys du
PAL ; fondation posée en couches 0 (couche-0-v1.9) et 1 (couche-1-v1.2), rien en
couche 2. (La face sigaction-de-faute n’est PAS un additif — les 4 fautes restent
inertes, ADR-064 §2 ; seul l’install de handler non-faute est descellé, ADR-066.)
Cette liste se complète au fil des points identifiés.
3. Méthode de production (rappel pour agents)
- Doctrine de couche : seule la couche 1 consomme la couche 0 ; les tiers couche 1
sont fidèles (un appel = un syscall), EINTR remonté (jamais de retry auto),
zéro
unsafeexposé, erreursAirError/AirResult, arithmétique défensive (jamaisaslossy), nommage ADR-029 (zéro abréviation). - Qualité, avant tout merge :
cargo fmt/clippy -D/test,cargo xtask barrier(WARN = vert ; 5 advisoryunsafepré-existantssecurity.rs/inotify.rsignorés), couverture 100 % lignes (exceptions STRUCTURAL = marqueur in-code// COVERAGE (STRUCTURAL, ADR-035), commeair-process/air-poll), fuzz sur toute surface de parsing d’octets externes (sinon « Note fuzz » justifiée dans la spec). - Git : trunk-based, tout par PR sauf doc pure → commit direct sur
main(pas de CI pour de la doc). DCO-s+ signature GPG (clé BDFL), jamais de trailerCo-Authored-By. Production déléguée àclaude -pheadless (carbon/speedy) ; le superviseur pilote validation + push + PR + merge (squash) + re-vérif main/trailer. - Nouvelle crate ⇒ committer
Cargo.lock(reproductibilité ADR-025).
4. Jalons réalisés (résumé — détail dans JOURNAL.md)
- Couche 0 — scellée
couche-0-v1(2026-06-14) puis re-scellée jusqu’àcouche-0-v1.8(2026-07-02, familleterminal/termios-tty, ADR-060), puis les descellements additifs de la campagne libc/std : v1.9 (signaux async, ADR-066), v1.10 (sockaddr/sockopt libc, ADR-070/071), v1.11 (additifs cumulés libc/std —chroot/killpg/sigaltstack/sendfile/fchmod/fchown/futimens/munmap_raw, ADR-085) et v1.12 le 2026-07-10 (raw_syscall, escape hatch syscall générique, exception temporaire à retirer, ADR-087, jalon M5). Code complet, plus d’évolution hors RFC (descellement additif). - Couche 1 — cœur livré :
air-base-lib(cœur + services),air-crypto,air-socket,air-filesystem,air-memory,air-device,air-thread,air-process,air-config,air-stdio,air-runtime, temps, i18n no_std (icu4x, ADR-059), futex maison, loom (ADR-058). - Couche 1 — SCELLÉE
couche-1-v1.0(2026-07-03, ADR-062) : campagne close. Audits pré-sceau face-libc (#212) / face-PAL (#213) ; les 4 P0 — AirFile (#214), air-poll (#215), termios/PTY (#216), air-signal (#217) ; remodel os_str env/args pré-sceau (#218) ; doctrines gravées ADR-063 (instrumentation IO) + ADR-064 (signaux, dont §7 barrière de poison). Re-scellée additivement jusqu’àcouche-1-v1.9(v1.1 errno_location ADR-065 ; v1.2 signaux/futex ADR-066 ; v1.3 air-account ADR-067 ; v1.4/v1.5 credentials + drop_to_user ADR-068 ; v1.6 air-handle + moteur socket ADR-069 ; v1.7 Managers de domaine ADR-077 ; v1.8spawn_processsocle posix_spawn ; v1.9 introspection thread cible ADR-086, jalon M5). Earmark ADR-078 cryptoair-tls→ v1.10. - 🎉 Jalon M5 —
stdtourne sur Air, zéro glibc (2026-07-10). Un programme Ruststd(hello-std, static-PIE) lié intégralement contrelibair(la libc d’Air, sans glibc) s’exécute on-target sur les 2 arches (hello from std on air+ exit 0 sur x86_64-air/carbon ET aarch64-air/raspi). Le lien passe de 12 → 0 symboles indéfinis (5 PR #308–#314 :__xpg_strerror_r,open/fcntl, introspection thread [ADR-086],getauxval/pthread_self/détection-pile, TLS keys,syscallgénérique [ADR-087]). Le pari ADR-076 (stdsur la libc d’Air) est tenu de bout en bout : toolchain Rust full-Air prouvée. Re-sceauxcouche-0-v1.12+couche-1-v1.9.
5. Dettes connues (à planifier, non bloquantes pour le sceau)
-
🔴 La couverture « couche 2 » n’est PAS reproductible hors CI — sa référence dépend d’un répertoire de build. Instruit le 2026-09-05, sur demande du BDFL.
Machine couche 2, CARGO_TARGET_DIRneufspeedy 66,91 % carbon 44,88 % hydrogen 35,16 % référence du gate ( couverture-reference.toml)77,11 Aucune machine du parc n’atteint 77,11 en mesure isolée — pas même carbon, qui l’a établie. La référence n’est atteinte qu’en CI, où le job enchaîne deux mesures (lignes puis branches) dans des répertoires persistants : au 2026-09-05,
couverture-lignes-couche2contenait 2 825.profrawetcouverture-branches-couche255. Le gate mesure donc l’état cumulé d’un répertoire, non la couverture d’un run.Ce qui est écarté, chacun vérifié :
sccache, les comptes système,io_uringbridé par sysctl, la toolchain (même pin, même hash sur les trois), les outils externes (tous présents), la charge machine, et l’exécution des tests — 1177 passent identiquement partout, et une crate isolée mesure pareil sur les trois (68,2–68,3 % pourair-sshd).Comment le défaut s’est révélé : le label
air-couverture-referencea été déplacé de carbon vers hydrogen, dont le répertoire était vierge. Le job y a rendu 35,09 % au lieu de 77 — non parce que la machine était défaillante, mais parce qu’elle était neuve. Le label est revenu sur speedy depuis, mais cela ne corrige rien : la première machine dont le répertoire sera purgé reproduira le symptôme.⚠️ Une mesure prise dans un répertoire déjà utilisé est FAUSSE, et silencieusement. Un relevé sur speedy avait rendu 77,18 % avec « 0 ligne non couverte sur 53 crates » — invraisemblable, et non relevé sur le moment. Dans un répertoire neuf : 66,91 %.
À trancher : soit le gate purge son répertoire avant de mesurer (et la référence est réétalonnée à ~67), soit il assume de mesurer un cumul et le dit. En l’état il affirme mesurer une couverture et mesure autre chose.
-
🔧 Escape hatch
syscall/raw_syscall(ADR-087) — à RETIRER avec l’équipe Rust. Le palstdde*-linux-airappelle quelques syscalls (gettid,futex…) via la fonction C génériquesyscall, ce qui a imposé unraw_syscallen couche 0 (entorse assumée à ADR-021 conv. 3 + à la règle « libc bind couche 1 »). Dette explicite : travailler avec l’équipe Rust pour que le palstdsur Air se passe dusyscallgénérique (chaque op via son wrapper typé), puis retirerraw_syscallde la couche 0. -
🔧
GlobalAllocsafe (air-alloc) — à RENDRE safe avec l’équipelibsRust. Doctrine fondatrice : leunsafedoit vivre en couche 0 uniquement (barrière kernel) ; l’impl deGlobalAllocparair-alloc(couche 1) resteunsafepar le trait. But = supprimer ceunsafedans le cas d’Air (action item amont teamslibsRust), même registre queraw_syscall. Voir la doctrineunsafe(macro-architecture, Section 1/2/8). -
Lane CI nightly
hello-std/rtà monter : le harnais M5 (rt/, hors-arbre) n’est pas encore construit/exécuté en CI. Leçon gravée : jamais différer les runs raspi. -
Gates de couverture couche 1 :
cargo xtask couvrable-vide/audit-exceptionsne réconcilient mécaniquement quecrates/air-sys-*(couche 0). Les crates couche 1 documentent leurs exceptions STRUCTURAL in-code. Étendre les gates à la couche 1 bute sur une collision de basename (time.rscouche 0 ET couche 1) → décision de design de gate (RFC/tâche séparée). -
mdBook non pinné — à figer pour la reproductibilité (ADR-025).
-
air-doc-publish.shvit hors dépôt (speedy) — à éventuellement versionner soustools/. -
Instrumentation IO (ADR-063) : décidée, à implémenter en additif
v1.x. -
🔬
remote_forward— l’hypothèse retenue depuis un mois est ÉCARTÉE (2026-09-04). Quatrième occurrence recensée, et la première qui apprend quelque chose — grâce à l’instrumentation de #916.Ce que le message dit désormais : « lecture de l’écho ÉCHOUÉE après 9,3 µs : Connection reset by peer (errno 104) — 0/39 octet(s) reçu(s) ». Et ce qu’il ne dit pas compte autant : aucune cause serveur n’a été restituée. Le hook s’armant à la première consignation, son silence signifie que
serve_ssh_connectionn’a pas échoué.Cela écarte la piste suivie jusqu’ici — un
binddu serveur qui échouait en silence, port éphémère volé par un test étranger (corrigé le 2026-08-16 en tirant sous 32768). Le serveur va bien.Nouvelle hypothèse, à confirmer :
connect_with_retryréessaie jusqu’à cent fois et aboutit, donc le port distant écoute. C’est l’écho qui est réinitialisé, immédiatement. La course ne serait donc pas sur lebindmais sur l’ouverture du canal — le serveur accepte sur le port distant et ouvre unforwarded-tcpipque le client n’est pas encore prêt à accueillir.Ce qui la confirmerait : faire dire au test l’état du canal côté client au moment du reset. Ne pas relancer en espérant : quatre occurrences ont déjà été relancées.
-
🔬
mediation.is_closed()d’air-sshd— rouge intermittent, JAMAIS reproduit. Consigné pour ne pas être redécouvert par accident : il n’a pas de cause établie, et aucune tentative de reproduction n’a abouti. Ce qui le traitera n’est pas une relance : il faut que le test dise ce qu’il a vu au moment où il échoue — l’état des deux côtés de la médiation —, faute de quoi chaque occurrence ne rend qu’un booléen faux. -
✅ SOLDÉE le 2026-09-02 — le bus n’encodait pas quatre rapports.
DependencyMissing,DependencyFailed,ReadyTimeoutetDevenuPretne sortaient que par le rapport texte — visibles à l’opérateur, invisibles à un abonné — alors que ADR-161 D6 exige que le lanceur les dise « nommément, sur son bus d’événements ». Ce sont les ordinaux 12 à 15 du schémalaunch_event, ajoutés ensemble comme le traitement le prévoyait : un schéma qu’on rallonge quatre fois brûle quatre fois des ordinaux pour un seul besoin. Ce qui reste muet —Signalled,Killed,StopRequested— n’est pas une dette : ces trois-là disent ce que le lanceur fait, non ce que le service est. C’est cette distinction qui avait été perdue : les quatre soldés ici étaient rangés avec eux par ressemblance (« ça ne s’encode pas »), et une dette dormait derrière une catégorie. -
⚠️ NOUVEAU (2026-09-02) — rien ne vérifie que le code capnp COMMITTÉ suit son schéma. ADR-040 grave la politique « code généré committé » : « un mainteneur régénère le code Rust uniquement quand un schéma change, et commite le
.rsgénéré ». C’est une obligation posée sur l’humain, qu’aucun contrôle ne relit — ni workflow, nixtask, ni script. Elle a été manquée : #894 a renomméreadyenreadyRetiredans le.capnple 2026-09-01 sans régénérer, et le.rscommitté est resté celui du 2026-08-10 — quatre jours durant, le code généré exposaitset_ready, un nom que le schéma ne contenait plus. Rien n’a rougi, et rien n’aurait rougi. Les cinq autres schémas du dépôt sont à jour, ce qui rend la lacune d’autant plus facile à ne pas voir. ✅ SOLDÉE le jour même parcargo xtask check-schemas, qui ne régénère pas — ce serait exiger en CI le tool C++ qu’ADR-040 en écarte délibérément. Il constate : le.rsporte en en-tête l’empreinte du schéma normalisé dont il est issu, et le gate la recalcule. Rien questd, aucun octet de C++. La normalisation retire les commentaires, et il le faut : un commentaire ne change pas le code généré — vérifié en régénérant après ajout d’une ligne, fichier identique au bit près. Sans elle, chaque reformulation rougirait sur des schémas très commentés, et le gate serait désarmé en pratique. Un#dans une chaîne fait refuser plutôt que tronquer. Ce qu’il ne voit pas, et qui est écrit dans sa doc : que le.rssoit correct (cela demanderait de régénérer), et qu’il ait été édité à la main. Il attrape le défaut observé — un schéma modifié sans régénération — et rien de plus. -
🔬
DEFAULT_MEMORY_HIGH_BYTESest mesurée mais ne dit ni OÙ ni QUAND. Son commentaire cite ses chiffres — « ~819 Kio par connexion, 1,5 Mio au repos » — mais ADR-174 D5 exige la machine et la date : une mesure sans machine n’est pas reproductible, une mesure sans date ne dit pas si elle a survécu au code. C’est la seule borne mesurée du lanceur, et il lui manque sa provenance. Relevé par #903, non corrigé — tant qu’elle n’est pas soldée, ADR-174 n’est pas intégralement réalisé. -
Flake préexistant
air-libc-stdio(révélé souscargo test --workspaceen forte charge, taux faible) : des tests inspectanterrnoaprès échec (fputs_null_and_write_on_readonly,close_reports_backend_error) échouent par intermittence — course probable sur l’état global (registreFILE) entre tests parallèles, bien qu’errnosoitthread_local. Hors périmètre M3 (crate non modifiée). À corriger séparément (sérialiser ces tests).
Un motif qui revient : le compteur global partagé entre tests parallèles.
Trois crates l’ont montré, et les trois de la même façon — un test lit un état global, agit, puis vérifie un delta, pendant qu’un autre test le modifie :
air-libc-stdio(registreFILE) — ci-dessus, ouvert ;air-value(OBJ_COUNT) — six tests sur sept prenaient le verrou, le septième l’oubliait. Corrigé (#899) ;air-com(FINALIZED) — aucun des deux tests ne prenait de verrou, faute de verrou. Corrigé (#914).Ces flakes ne se voient qu’en forte charge, donc surtout sur
carbonsousllvm-cov, et ils font échouer des PR qui ne touchent pas la crate en cause. Devant un rouge intermittent dans un test qui compte quelque chose, chercher l’état global avant de relancer.
Ce document se met à jour à la main au fil des merges. Pour l’historique fin
(session par session, PR par PR), voir JOURNAL.md.