Inventaire des additifs couches 0 / 1 accumulés depuis le dernier sceau
Note d’intendance, ouverte le 2026-08-04. Vivante jusqu’au re-sceau, puis figée.
Pourquoi cette note
La directive en vigueur est de ne plus re-sceller la couche 1 par incrément mais de
poser un seul re-sceau à la complétion d’air-ssh/air-sshd. La conséquence
mécanique est que les additifs s’accumulent sans trace consolidée : chacun vit dans le
message de sa PR, et l’inventaire n’existe nulle part au moment où il faudra décider du
numéro de version à poser.
Cette note est cet inventaire. Elle se lit au moment du re-sceau et répond à trois questions : quoi a été ajouté, est-ce additif ou rupture, et quel numéro de version en découle (ADR-123 : une rupture exige un majeur dédié et l’autorisation du BDFL).
Points de départ de l’inventaire :
| Couche | Dernier sceau posé | Commit | Date |
|---|---|---|---|
| Couche 0 | couche-0-v1.15 | aa141863 (#510) | 2026-07-29 |
| Couche 1 | couche-1-v3.8 | 9b2d51e2 (#528) | 2026-07-30 |
Méthode de relevé (reproductible) — le diff de surface publique des crates qui
déclarent leur couche dans [package.metadata.air] :
L1=$(grep -rl '^layer = 1' crates/*/Cargo.toml | xargs -n1 dirname)
git diff couche-1-v3.8..main --unified=0 -- $L1 \
| grep -E '^\+' | grep -E 'pub (fn|struct|enum|const|type|trait) '
Couche 0 — 2 additifs non scellés
| Symbole | Crate | PR | Nature |
|---|---|---|---|
fchmodat2 + FCHMODAT2 (452) + ChmodFlags | air-sys-syscall | #568 | additif |
LandlockAccessNet (air-sys-types) + landlock_create_ruleset_with_net + LandlockRuleset::add_rule_net_port (air-sys-syscall) | couche 0 | Landlock v4 | additif — le volet réseau de Landlock (ABI v4, Linux 6.7). Aucun syscall nouveau : landlock_create_ruleset/landlock_add_rule étaient déjà là, il manquait la forme d’attributs à deux champs et le type de règle NET_PORT. La forme v1 est conservée : le noyau lit exactement size octets, et lui en passer seize quand il n’en connaît que huit échoue E2BIG — ce qui, la pose de cage étant fail-closed, tuerait le processus sur un noyau parfaitement valide. Deux fonctions plutôt qu’une dégradation silencieuse |
Le commit porte la mention « descellement couche 0 » mais aucun tag couche-0-v1.16
n’a été posé : la couche 0 est, à ce jour, descellée sans re-sceau. C’est le seul
écart de la couche 0 depuis v1.15.
Motivation : durcissement anti-TOCTOU des permissions des sockets streamlocal.
fchmodat2 est la variante moderne qui accepte AT_SYMLINK_NOFOLLOW — ce que
fchmodat ne sait pas faire — donc le seul appel qui permette de poser un mode sans
suivre un lien symbolique posé par un tiers entre la création et le chmod.
Couche 1 — 17 additifs non scellés (+ 1 crate nouvelle)
Tous strictement additifs : ajout de symboles ou élargissement d’une borne. Aucun
retrait, aucun changement de signature, aucun changement de sémantique d’un symbole
existant. Aucune rupture ⇒ le re-sceau attendu est un mineur (couche-1-v3.9),
pas le majeur v4.0 d’ADR-123.
| Symbole | Crate | PR | Ce qu’il apporte |
|---|---|---|---|
ct_eq_bytes | air-crypto | #554 | comparaison d’octets en temps constant — support du correctif de fuite temporelle d’énumération de comptes |
peer_credentials (fn libre + méthode) | air-socket | #545 | SO_PEERCRED : uid/gid/pid du pair d’une socket Unix — l’agent en a besoin pour n’accepter que son propriétaire |
change_mode_nofollow | air-filesystem | #568 | face couche 1 de fchmodat2 ci-dessus (AT_SYMLINK_NOFOLLOW) |
is_connect_in_progress | air-socket | #585 | prédicat nommé sur EINPROGRESS — rend lisible le connect(2) non bloquant du monitor |
assume_with_supplementary_groups | air-process | #600 | bascule d’identité temporaire avec les groupes supplémentaires (parité de droits pour streamlocal) |
MAX_KEPT_FDS 16 → 48 | air-process | #594 | élargissement de la borne de fds préservés au fork (coexistence SFTP cagé + forwards) — const privée, pas un symbole public |
signal_by_pidfd | air-process | #606 | signaler par pidfd et non par PID — pas de fenêtre de réutilisation de PID (médiation SpawnSession) |
parent_death_signal (builder AirCommand) | air-process | #613 | PR_SET_PDEATHSIG optionnel, armé sur le shell de session |
default_sigpipe_on_exec (builder AirCommand) | air-process | #629 | rétablit SIG_DFL(SIGPIPE) dans l’enfant — ferme la fenêtre process-globale du correctif SIGPIPE |
inherit_fd (builder AirCommand) | air-process | #631 | installe un descripteur sur un numéro dit de l’enfant, au-delà de 0/1/2 — le lanceur y remet son canal (ADR-151) |
join_cgroup (builder AirCommand) | air-process | #649 | joint l’enfant à un cgroup avant son execve — donc avant qu’il ait pu forker, ce dont dépend entièrement cgroup.kill. Écrire depuis le parent après le clone3 laisserait une course, et une course sur une frontière de sécurité n’est pas une frontière |
new_session (builder AirCommand) | air-process | #648 | setsid sans terminal de contrôle sur le chemin exec — il n’y était atteignable que par login_terminal, qui exige un PTY et écrase les trois flux standard. C’est ce qui rend un service chef de son groupe, donc arrêtable avec sa descendance (limite A-2 du lanceur, spec §4.5) |
make_directory_all | air-filesystem | #647 | crée une arborescence dont chaque répertoire créé porte le mode demandé exactement — mkdir(2) n’applique que mode & ~umask, et un composant qui décide de la confiance ne peut pas dépendre de l’umask du shell qui l’a lancé. Ferme le seul vrai downgrade qu’ait trouvé la revue adverse de l’épinglage (~/.config/air/ naissait en 0777 sous umask 0) |
AirPortAccess + AirPortRule + allow_port + handled_net + SyscallCategory::Network + AirSandboxError::NetworkUnsupported | air-sandbox | Landlock v4 | le volet réseau de la cage. Landlock borne les ports, seccomp ouvre les verbes : ni l’un ni l’autre ne suffit seul. NetworkUnsupported est un refus, jamais une dégradation — poser la cage sans sa part réseau rendrait un processus qui se croit borné sans l’être. L’enum étant #[non_exhaustive], la variante est un additif, pas une rupture |
SyscallCategory + compose | air-sandbox | ADR-150 / grain seccomp | nomme la structure que les profils pratiquaient déjà : sftp_worker contenait exactement pre_auth plus 24 syscalls formant la catégorie « fichiers ». Les profils figés restent inchangés (surface scellée) ; un test vérifie désormais qu’ils valent exactement la composition de leurs catégories — la duplication demeure dans le texte, elle cesse d’être silencieuse. recvmsg sort du socle : c’est une catégorie à part (FdPassing), sans quoi tout processus confiné pourrait recevoir n’importe quel descripteur |
ManifestDomain + GrantDomain + SignatureDomain (+ 3 entrées dans DOMAINS) | air-config, air-config-schema | ADR-150 inc.1-2 | les deux artefacts d’ADR-150 entrent dans le registre des domaines — ce qui leur donne le compilateur texte→binaire déjà borné et déjà fuzzé, au lieu d’un second parseur au voisinage d’une décision de privilèges. Les marqueurs ne sont pas des commodités : ce sont les gardes qui interdisent de lire un octroi pour un manifeste, la confusion qui rendrait l’invariant « l’octroi n’accorde jamais plus » vrai par construction, donc vide |
AirFileTrust + AirFileSecrecy | air-filesystem | ADR-010 t.8 | la garde de confiance de fichier, une seule fois. Elle existait en DEUX exemplaires — AirStoreTrust (air-keystore) et ArtifactTrust (air-launchd) — pour la même question posée au même moment. Le dépôt s’était déjà fait prendre à ce jeu : la protection avait été écrite là où le besoin s’était fait sentir plutôt que là où le domaine vit, si bien que known_hosts (public) était gardé et hostkeys.aircfg (clé privée d’hôte) ne l’était pas. Le lanceur, en couche 2, supprime son type ; le magasin, en couche 1 scellée, garde AirStoreTrust comme surface publique et délègue — non cassant, et les deux noms pourront fusionner au descellement. Les bits interdits (0o022 écriture, 0o077 secret) n’existent plus qu’ici |
AirMachineKey + machine_key() / set_machine_key() / machine_public_key() + 2 variantes d’AirKeystoreArtifact | air-keystore | ADR-010 t.6b | le second signataire d’ADR-150 : le manifeste est signé par le développeur, l’octroi par la machine. Sans cette clé, l’étape 2 de l’arbitrage vérifierait qu’un octroi est signé — par n’importe qui. Générée au premier démarrage (image anonyme, ADR-121), donc impossible à graver dans l’image. Deux fichiers écrits d’un seul geste : la graine (0600) et sa contrepartie publique (0640) — le lanceur vérifie, il ne signe jamais, et une clé publique qui pourrait diverger de sa privée ferait échouer toutes les activations sans rien dire d’utile. AirKeystoreArtifact n’étant pas #[non_exhaustive], les deux variantes sont une rupture pour un match exhaustif externe — il n’en existe aucun hors de la crate, à vérifier au re-sceau |
clear_signal_mask (builder AirCommand) | air-process | #631 | vide le masque de signaux de l’enfant avant l’execve — ferme un piège silencieux : le masque d’un superviseur est hérité à travers fork et execve, si bien qu’un service lancé démarrait SIGTERM bloqué, donc insensible à tout sauf SIGKILL (ADR-151) |
send_with_fd / recv_with_fd | air-socket | ADR-157 / 2b-ii | un message et son descripteur dans le MÊME datagramme. AirUnixSeqpacket savait envoyer un fd seul (send_fd) ou un message seul (send/write_packet), jamais les deux — or c’est exactement ce que SCM_RIGHTS sert à garantir. Un protocole où le descripteur voyagerait dans un message séparé de celui qui le décrit laisserait les deux se désolidariser : un pair tiendrait un fd sans savoir ce qu’il désigne, ou une demande sans son fd. Sur un SEQPACKET, l’ancillaire est attaché au datagramme — ils arrivent ensemble ou pas du tout. Requis par le protocole du registre AirCom, dont « enregistre-moi sous ce nom, voici mon canal d’acceptation » est une phrase et non deux. L’absence de descripteur n’est pas une erreur : c’est ce que dit un message qui n’en portait pas |
AirScope + AirLandlockPolicy::scope() / scoped() + AirSandboxError::ScopeUnsupported | air-sandbox | ADR-156 D4 | le scoping Landlock v6 (Linux 6.12) : ce qu’un domaine peut atteindre HORS de lui-même. Deux voies latérales qu’aucune règle par chemin ne pouvait voir — un socket UNIX abstrait n’a pas d’entrée dans le système de fichiers, et un signal ne traverse aucun descripteur. Deux services confinés séparément pouvaient donc dialoguer sans que leurs cages en sachent rien, y compris un service dont le système de fichiers avait ENTIÈREMENT disparu — c’est le cas de l’étage pré-auth d’air-sshd. Surface à part et non un AirPathAccess de plus (D4) : une portée n’est pas un droit sur une ressource, et les mêler donnerait une règle sans chemin. La borne est sortante — un lanceur non confiné garde la main sur ce qu’il a lancé — et deny_all() la pose d’office, une fermeture totale devant fermer tout ce que le noyau sait fermer (D3). Corollaire assumé : deny_all() refuse de se poser sous ABI v6, ce qui est le plancher d’Air (ADR-004) |
AirPreparedCage::apply_to_all_threads() + AirSandboxError::ThreadSyncUnsupported | air-sandbox | ADR-156 | la cage sur TOUS les fils (LANDLOCK_RESTRICT_SELF_TSYNC + SECCOMP_FILTER_FLAG_TSYNC). Sans elle, restrict_self et seccomp ne bornent que le fil appelant : un processus multifil qui se confine lui-même laisse ses fils frères dehors, et la cage se contourne en changeant de fil. Opt-in, et non un défaut — mesuré le 2026-08-13 : TSYNC ÉCRASE le domaine des fils frères, y compris quand le leur était plus strict (un frère qui ne pouvait rien ouvrir a retrouvé l’accès). La poser d’office relâcherait donc, en silence, un fil plus étroitement confiné. TSYNC va toujours avec TSYNC_ESRCH côté seccomp : seul, il rend le tid du fil fautif — un entier positif — que la couche 0 lit comme un succès |
Une crate de plus, et ce qu’elle change au décompte
air-cgroup (#649, étendue par #650) n’est pas un additif à une surface existante : c’est une crate
couche 1 nouvelle, AirCgroupManager. Elle ne descelle rien — aucun syscall n’y est
enveloppé, les cgroups se pilotant entièrement par le système de fichiers — et elle
n’élargit aucune surface scellée. Au re-sceau, elle relève donc du mineur au même titre
que les additifs, et pour la même raison : rien de ce qui existait ne change.
Lecture d’ensemble
Sept des dix-sept additifs sortent du même chantier : la séparation de privilèges
d’air-sshd (ADR-129/146/147). Ce n’est pas une coïncidence, et c’est plutôt une
bonne nouvelle pour le sceau : ils ne sont pas des commodités demandées par du code de
couche 2, mais des capacités kernel que la couche 1 devait de toute façon exposer
pour qu’un toit puisse être écrit sans contourner la doctrine de couche
(règle de layering stricte : un manque en couche 1 se comble par un additif de
couche 1, jamais par un accès direct à la couche 0).
Le quinzième sort d’ailleurs : il n’ajoute aucune capacité kernel, il inscrit deux domaines dans un registre existant. C’est le bon signe d’une couche 1 qui a mûri — la couche 2 y trouve de quoi faire sans rien y ajouter d’autre que du vocabulaire.
join_cgroup (#649) est le pendant exact de new_session, un cran plus bas :
là où new_session donnait au superviseur un groupe à viser, celui-ci lui donne une
frontière que la descendance ne peut pas quitter. Les deux ont la même forme — un geste
posé dans l’enfant, au seul instant où il est sans course — et c’est cette forme, plus que
la capacité, qui devrait guider les additifs suivants.
Le treizième (new_session, #648) rejoint la famille des trois ci-dessous : fermer une
fenêtre autour du cycle de vie d’un enfant. Il a ceci de particulier que la capacité
existait déjà dans la crate — setsid y est appelé par login_terminal — mais liée à
un terminal de contrôle dont un service supervisé ne veut pas. L’additif ne fait que
délier ce qui était couplé : c’est la forme la moins chère qu’un additif puisse
prendre, et celle qu’il faut chercher en premier.
Le douzième (make_directory_all, #647) est d’une autre famille et mérite d’être lu comme
tel : ce n’est pas une capacité kernel qui manquait — mkdirat et fchmodat2 étaient déjà
là, et AirFileManager::make_directory posait déjà un mode — mais une composition que
chaque toit aurait dû réécrire, avec la discipline « créer puis corriger le mode, en
no-follow » que personne ne réinvente deux fois de la même manière. C’est le bon critère
pour un additif de couche 1 : non pas « le toit en a besoin », mais « le toit le referait
mal ».
Trois d’entre eux — signal_by_pidfd, parent_death_signal, default_sigpipe_on_exec
— relèvent d’ailleurs de la même famille : fermer une fenêtre de course autour du
cycle de vie d’un processus enfant. Les deux derniers (#631, ADR-151) sortent du même
tonneau : clear_signal_mask ferme précisément une de ces fenêtres, et elle n’a été
trouvée ni par la revue ni par les tests, mais par une exécution réelle — ce qui
plaide encore pour la relecture d’ensemble suggérée ci-dessous. S’il devait rester une dette de cohérence à
regarder au re-sceau, c’est celle-là : ces trois-là mériteront peut-être d’être relus
ensemble comme une surface, plutôt que comme trois ajouts indépendants.
Ce qu’il reste à faire au moment du re-sceau
- Rejouer le relevé ci-dessus (la commande est donnée) — cette note est un état au 2026-08-04, pas une vérité perpétuelle. Tout ce qui a été mergé après doit s’y ajouter.
- Trancher la couche 0 : poser
couche-0-v1.16pour régulariserfchmodat2, ou l’absorber dans un sceau conjoint. - Poser
couche-1-v3.9(mineur) si l’inventaire reste sans rupture. Si une rupture est apparue entre-temps, basculer sur la voie majeure d’ADR-123 : PR de rupture dédiée et autorisation explicite du BDFL. Le tag est posé par le superviseur, après CI verte. - Mettre à jour le suivi (
docs/etat-avancement.md) et leJOURNAL.md.