Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 :

CoucheDernier sceau poséCommitDate
Couche 0couche-0-v1.15aa141863 (#510)2026-07-29
Couche 1couche-1-v3.89b2d51e2 (#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

SymboleCratePRNature
fchmodat2 + FCHMODAT2 (452) + ChmodFlagsair-sys-syscall#568additif
LandlockAccessNet (air-sys-types) + landlock_create_ruleset_with_net + LandlockRuleset::add_rule_net_port (air-sys-syscall)couche 0Landlock v4additif — 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.

SymboleCratePRCe qu’il apporte
ct_eq_bytesair-crypto#554comparaison d’octets en temps constant — support du correctif de fuite temporelle d’énumération de comptes
peer_credentials (fn libre + méthode)air-socket#545SO_PEERCRED : uid/gid/pid du pair d’une socket Unix — l’agent en a besoin pour n’accepter que son propriétaire
change_mode_nofollowair-filesystem#568face couche 1 de fchmodat2 ci-dessus (AT_SYMLINK_NOFOLLOW)
is_connect_in_progressair-socket#585prédicat nommé sur EINPROGRESS — rend lisible le connect(2) non bloquant du monitor
assume_with_supplementary_groupsair-process#600bascule d’identité temporaire avec les groupes supplémentaires (parité de droits pour streamlocal)
MAX_KEPT_FDS 16 → 48air-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_pidfdair-process#606signaler 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#613PR_SET_PDEATHSIG optionnel, armé sur le shell de session
default_sigpipe_on_exec (builder AirCommand)air-process#629rétablit SIG_DFL(SIGPIPE) dans l’enfant — ferme la fenêtre process-globale du correctif SIGPIPE
inherit_fd (builder AirCommand)air-process#631installe 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#649joint 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#648setsid 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_allair-filesystem#647crée une arborescence dont chaque répertoire créé porte le mode demandé exactementmkdir(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::NetworkUnsupportedair-sandboxLandlock v4le 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 + composeair-sandboxADR-150 / grain seccompnomme 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-schemaADR-150 inc.1-2les 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 + AirFileSecrecyair-filesystemADR-010 t.8la 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’AirKeystoreArtifactair-keystoreADR-010 t.6ble 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#631vide le masque de signaux de l’enfant avant l’execveferme 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_fdair-socketADR-157 / 2b-iiun 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::ScopeUnsupportedair-sandboxADR-156 D4le 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::ThreadSyncUnsupportedair-sandboxADR-156la 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

  1. 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.
  2. Trancher la couche 0 : poser couche-0-v1.16 pour régulariser fchmodat2, ou l’absorber dans un sceau conjoint.
  3. 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.
  4. Mettre à jour le suivi (docs/etat-avancement.md) et le JOURNAL.md.